Compare commits
23 Commits
main
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfaff46d59 | ||
|
|
0ebf75b24e | ||
|
|
7b46e06f8b | ||
|
|
0737db69a9 | ||
|
|
64b4a65e7b | ||
|
|
7f0d2b54e0 | ||
|
|
5b1a1d0b0a | ||
|
|
0562b94b03 | ||
|
|
ef08512986 | ||
|
|
816d4b89ce | ||
|
|
aa1379c15b | ||
|
|
33e589f041 | ||
|
|
b12dc6257d | ||
|
|
35e0f06a77 | ||
|
|
dde187e0b2 | ||
|
|
5a7c61ade7 | ||
|
|
d2bce85f9c | ||
|
|
e509463911 | ||
|
|
db08f523f6 | ||
|
|
c5fa159c14 | ||
|
|
8645c7dcc8 | ||
|
|
fbeb84270b | ||
|
|
c1ac9b5bc4 |
@@ -91,7 +91,6 @@
|
||||
--space-4xl: 4rem;
|
||||
|
||||
/* Typography Scale */
|
||||
--text-2xs: 0.625rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
@@ -205,6 +204,14 @@
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
|
||||
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
||||
--font-size-5xs: 0.4375rem;
|
||||
--font-size-4xs: 0.5rem;
|
||||
--font-size-3xs: 0.5625rem;
|
||||
--font-size-2xs: 0.625rem;
|
||||
/* Monospace label tracking — used in Loader and Footnote */
|
||||
--tracking-wider-mono: 0.2em;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -265,6 +272,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 21× border-black/5 dark:border-white/10 → single token */
|
||||
.border-subtle {
|
||||
@apply border-black/5 dark:border-white/10;
|
||||
}
|
||||
/* Secondary text pair */
|
||||
.text-secondary {
|
||||
@apply text-neutral-500 dark:text-neutral-400;
|
||||
}
|
||||
/* Standard focus ring */
|
||||
.focus-ring {
|
||||
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global utility - useful across your app */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
|
||||
@@ -85,19 +85,11 @@ onDestroy(() => themeManager.destroy());
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
<!-- </main> -->
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -34,11 +34,17 @@
|
||||
* A breadcrumb item representing a tracked section
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
/** Unique index for ordering */
|
||||
/**
|
||||
* Unique index for ordering
|
||||
*/
|
||||
index: number;
|
||||
/** Display title for the breadcrumb */
|
||||
/**
|
||||
* Display title for the breadcrumb
|
||||
*/
|
||||
title: string;
|
||||
/** DOM element to track */
|
||||
/**
|
||||
* DOM element to track
|
||||
*/
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
@@ -50,21 +56,37 @@ export interface BreadcrumbItem {
|
||||
* past while moving down the page.
|
||||
*/
|
||||
class ScrollBreadcrumbsStore {
|
||||
/** All tracked breadcrumb items */
|
||||
/**
|
||||
* All tracked breadcrumb items
|
||||
*/
|
||||
#items = $state<BreadcrumbItem[]>([]);
|
||||
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
|
||||
/**
|
||||
* Set of indices that have scrolled past (exited viewport while scrolling down)
|
||||
*/
|
||||
#scrolledPast = $state<Set<number>>(new Set());
|
||||
/** Intersection Observer instance */
|
||||
/**
|
||||
* Intersection Observer instance
|
||||
*/
|
||||
#observer: IntersectionObserver | null = null;
|
||||
/** Offset for smooth scrolling (sticky header height) */
|
||||
/**
|
||||
* Offset for smooth scrolling (sticky header height)
|
||||
*/
|
||||
#scrollOffset = 0;
|
||||
/** Current scroll direction */
|
||||
/**
|
||||
* Current scroll direction
|
||||
*/
|
||||
#isScrollingDown = $state(false);
|
||||
/** Previous scroll Y position to determine direction */
|
||||
/**
|
||||
* Previous scroll Y position to determine direction
|
||||
*/
|
||||
#prevScrollY = 0;
|
||||
/** Throttled scroll handler */
|
||||
/**
|
||||
* Throttled scroll handler
|
||||
*/
|
||||
#handleScroll: (() => void) | null = null;
|
||||
/** Listener count for cleanup */
|
||||
/**
|
||||
* Listener count for cleanup
|
||||
*/
|
||||
#listenerCount = 0;
|
||||
|
||||
/**
|
||||
@@ -141,17 +163,23 @@ class ScrollBreadcrumbsStore {
|
||||
this.#detachScrollListener();
|
||||
}
|
||||
|
||||
/** All tracked items sorted by index */
|
||||
/**
|
||||
* All tracked items sorted by index
|
||||
*/
|
||||
get items(): BreadcrumbItem[] {
|
||||
return this.#items.slice().sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
|
||||
/**
|
||||
* Items that have scrolled past viewport top (visible in breadcrumbs)
|
||||
*/
|
||||
get scrolledPastItems(): BreadcrumbItem[] {
|
||||
return this.items.filter(item => this.#scrolledPast.has(item.index));
|
||||
}
|
||||
|
||||
/** Index of the most recently scrolled item (active section) */
|
||||
/**
|
||||
* Index of the most recently scrolled item (active section)
|
||||
*/
|
||||
get activeIndex(): number | null {
|
||||
const past = this.scrolledPastItems;
|
||||
return past.length > 0 ? past[past.length - 1].index : null;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) {
|
||||
flex items-center justify-between
|
||||
z-40
|
||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||
|
||||
@@ -97,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
|
||||
* Includes pagination metadata alongside font data
|
||||
*/
|
||||
export interface ProxyFontsResponse {
|
||||
/** Array of unified font objects */
|
||||
/**
|
||||
* List of font objects returned by the proxy
|
||||
*/
|
||||
fonts: UnifiedFont[];
|
||||
|
||||
/** Total number of fonts matching the query */
|
||||
/**
|
||||
* Total number of matching fonts (ignoring limit/offset)
|
||||
*/
|
||||
total: number;
|
||||
|
||||
/** Limit used for this request */
|
||||
/**
|
||||
* Page size used for the request
|
||||
*/
|
||||
limit: number;
|
||||
|
||||
/** Offset used for this request */
|
||||
/**
|
||||
* Start index for the result set
|
||||
*/
|
||||
offset: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
|
||||
/** Valid font weight values (100-900 in increments of 100) */
|
||||
/**
|
||||
* Valid font weight values (100-900 in increments of 100)
|
||||
*/
|
||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,31 +1,3 @@
|
||||
/**
|
||||
* Mock font filter data
|
||||
*
|
||||
* Factory functions and preset mock data for font-related filters.
|
||||
* Used in Storybook stories for font filtering components.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* createMockFilter,
|
||||
* MOCK_FILTERS,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
*
|
||||
* // Create a custom filter
|
||||
* const customFilter = createMockFilter({
|
||||
* properties: [
|
||||
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
||||
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* // Use preset filters
|
||||
* const categoriesFilter = MOCK_FILTERS.categories;
|
||||
* const subsetsFilter = MOCK_FILTERS.subsets;
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
@@ -34,13 +6,13 @@ import type {
|
||||
import type { Property } from '$shared/lib';
|
||||
import { createFilter } from '$shared/lib';
|
||||
|
||||
// TYPE DEFINITIONS
|
||||
|
||||
/**
|
||||
* Options for creating a mock filter
|
||||
*/
|
||||
export interface MockFilterOptions {
|
||||
/** Filter properties */
|
||||
/**
|
||||
* Initial set of properties for the mock filter
|
||||
*/
|
||||
properties: Property<string>[];
|
||||
}
|
||||
|
||||
@@ -48,16 +20,20 @@ export interface MockFilterOptions {
|
||||
* Preset mock filters for font filtering
|
||||
*/
|
||||
export interface MockFilters {
|
||||
/** Provider filter (Google, Fontshare) */
|
||||
/**
|
||||
* Provider filter (Google, Fontshare)
|
||||
*/
|
||||
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
||||
/** Category filter (sans-serif, serif, display, etc.) */
|
||||
/**
|
||||
* Category filter (sans-serif, serif, display, etc.)
|
||||
*/
|
||||
categories: ReturnType<typeof createFilter<FontCategory>>;
|
||||
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
||||
/**
|
||||
* Subset filter (latin, latin-ext, cyrillic, etc.)
|
||||
*/
|
||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||
}
|
||||
|
||||
// FONT CATEGORIES
|
||||
|
||||
/**
|
||||
* Unified categories (combines both providers)
|
||||
*/
|
||||
@@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
||||
{ id: 'script', name: 'Script', value: 'script' },
|
||||
];
|
||||
|
||||
// FONT SUBSETS
|
||||
|
||||
/**
|
||||
* Common font subsets
|
||||
*/
|
||||
@@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||
];
|
||||
|
||||
// FONT PROVIDERS
|
||||
|
||||
/**
|
||||
* Font providers
|
||||
*/
|
||||
@@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||
];
|
||||
|
||||
// FILTER FACTORIES
|
||||
|
||||
/**
|
||||
* Create a mock filter from properties
|
||||
*/
|
||||
@@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
||||
return createFilter<FontProvider>({ properties });
|
||||
}
|
||||
|
||||
// PRESET FILTERS
|
||||
|
||||
/**
|
||||
* Preset mock filters - use these directly in stories
|
||||
*/
|
||||
@@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
||||
}),
|
||||
};
|
||||
|
||||
// GENERIC FILTER MOCKS
|
||||
|
||||
/**
|
||||
* Create a mock filter with generic string properties
|
||||
* Useful for testing generic filter components
|
||||
@@ -239,7 +205,9 @@ export function createGenericFilter(
|
||||
* Preset generic filters for testing
|
||||
*/
|
||||
export const GENERIC_FILTERS = {
|
||||
/** Small filter with 3 items */
|
||||
/**
|
||||
* Small filter with 3 items
|
||||
*/
|
||||
small: createFilter({
|
||||
properties: [
|
||||
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
||||
@@ -247,7 +215,9 @@ export const GENERIC_FILTERS = {
|
||||
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
||||
],
|
||||
}),
|
||||
/** Medium filter with 6 items */
|
||||
/**
|
||||
* Medium filter with 6 items
|
||||
*/
|
||||
medium: createFilter({
|
||||
properties: [
|
||||
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
||||
@@ -258,7 +228,9 @@ export const GENERIC_FILTERS = {
|
||||
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
||||
],
|
||||
}),
|
||||
/** Large filter with 12 items */
|
||||
/**
|
||||
* Large filter with 12 items
|
||||
*/
|
||||
large: createFilter({
|
||||
properties: [
|
||||
{ id: 'jan', name: 'January', value: 'jan' },
|
||||
@@ -275,7 +247,9 @@ export const GENERIC_FILTERS = {
|
||||
{ id: 'dec', name: 'December', value: 'dec' },
|
||||
],
|
||||
}),
|
||||
/** Filter with some pre-selected items */
|
||||
/**
|
||||
* Filter with some pre-selected items
|
||||
*/
|
||||
partial: createFilter({
|
||||
properties: [
|
||||
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
||||
@@ -284,7 +258,9 @@ export const GENERIC_FILTERS = {
|
||||
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
||||
],
|
||||
}),
|
||||
/** Filter with all items selected */
|
||||
/**
|
||||
* Filter with all items selected
|
||||
*/
|
||||
allSelected: createFilter({
|
||||
properties: [
|
||||
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
||||
@@ -292,7 +268,9 @@ export const GENERIC_FILTERS = {
|
||||
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
||||
],
|
||||
}),
|
||||
/** Empty filter (no items) */
|
||||
/**
|
||||
* Empty filter (no items)
|
||||
*/
|
||||
empty: createFilter({
|
||||
properties: [],
|
||||
}),
|
||||
|
||||
@@ -51,23 +51,41 @@ import type {
|
||||
* Options for creating a mock UnifiedFont
|
||||
*/
|
||||
export interface MockUnifiedFontOptions {
|
||||
/** Unique identifier (default: derived from name) */
|
||||
/**
|
||||
* Unique identifier (default: derived from name)
|
||||
*/
|
||||
id?: string;
|
||||
/** Font display name (default: 'Mock Font') */
|
||||
/**
|
||||
* Font display name (default: 'Mock Font')
|
||||
*/
|
||||
name?: string;
|
||||
/** Font provider (default: 'google') */
|
||||
/**
|
||||
* Font provider (default: 'google')
|
||||
*/
|
||||
provider?: FontProvider;
|
||||
/** Font category (default: 'sans-serif') */
|
||||
/**
|
||||
* Font category (default: 'sans-serif')
|
||||
*/
|
||||
category?: FontCategory;
|
||||
/** Font subsets (default: ['latin']) */
|
||||
/**
|
||||
* Font subsets (default: ['latin'])
|
||||
*/
|
||||
subsets?: FontSubset[];
|
||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||
/**
|
||||
* Font variants (default: ['regular', '700', 'italic', '700italic'])
|
||||
*/
|
||||
variants?: FontVariant[];
|
||||
/** Style URLs (if not provided, mock URLs are generated) */
|
||||
/**
|
||||
* Style URLs (if not provided, mock URLs are generated)
|
||||
*/
|
||||
styles?: FontStyleUrls;
|
||||
/** Metadata overrides */
|
||||
/**
|
||||
* Metadata overrides
|
||||
*/
|
||||
metadata?: Partial<FontMetadata>;
|
||||
/** Features overrides */
|
||||
/**
|
||||
* Features overrides
|
||||
*/
|
||||
features?: Partial<FontFeatures>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT STORE HELPERS
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for TanStack Query stores and state management.
|
||||
* Used in Storybook stories for components that use reactive stores.
|
||||
*
|
||||
@@ -35,27 +31,73 @@ import {
|
||||
generateMockFonts,
|
||||
} from './fonts.mock';
|
||||
|
||||
// TANSTACK QUERY MOCK TYPES
|
||||
|
||||
/**
|
||||
* Mock TanStack Query state
|
||||
*/
|
||||
export interface MockQueryState<TData = unknown, TError = Error> {
|
||||
/**
|
||||
* Primary query status (pending, success, error)
|
||||
*/
|
||||
status: QueryStatus;
|
||||
/**
|
||||
* Payload data (present on success)
|
||||
*/
|
||||
data?: TData;
|
||||
/**
|
||||
* Caught error object (present on error)
|
||||
*/
|
||||
error?: TError;
|
||||
/**
|
||||
* True if initial load is in progress
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* True if background fetch is in progress
|
||||
*/
|
||||
isFetching?: boolean;
|
||||
/**
|
||||
* True if query resolved successfully
|
||||
*/
|
||||
isSuccess?: boolean;
|
||||
/**
|
||||
* True if query failed
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* True if query is waiting to be executed
|
||||
*/
|
||||
isPending?: boolean;
|
||||
/**
|
||||
* Timestamp of last successful data retrieval
|
||||
*/
|
||||
dataUpdatedAt?: number;
|
||||
/**
|
||||
* Timestamp of last recorded error
|
||||
*/
|
||||
errorUpdatedAt?: number;
|
||||
/**
|
||||
* Total number of consecutive failures
|
||||
*/
|
||||
failureCount?: number;
|
||||
/**
|
||||
* Detailed reason for the last failure
|
||||
*/
|
||||
failureReason?: TError;
|
||||
/**
|
||||
* Number of times an error has been caught
|
||||
*/
|
||||
errorUpdateCount?: number;
|
||||
/**
|
||||
* True if currently refetching in background
|
||||
*/
|
||||
isRefetching?: boolean;
|
||||
/**
|
||||
* True if refetch attempt failed
|
||||
*/
|
||||
isRefetchError?: boolean;
|
||||
/**
|
||||
* True if query is paused (e.g. offline)
|
||||
*/
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
@@ -63,26 +105,72 @@ export interface MockQueryState<TData = unknown, TError = Error> {
|
||||
* Mock TanStack Query observer result
|
||||
*/
|
||||
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||
/**
|
||||
* Current observer status
|
||||
*/
|
||||
status?: QueryStatus;
|
||||
/**
|
||||
* Cached or active data payload
|
||||
*/
|
||||
data?: TData;
|
||||
/**
|
||||
* Caught error from the observer
|
||||
*/
|
||||
error?: TError;
|
||||
/**
|
||||
* Loading flag for the observer
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Fetching flag for the observer
|
||||
*/
|
||||
isFetching?: boolean;
|
||||
/**
|
||||
* Success flag for the observer
|
||||
*/
|
||||
isSuccess?: boolean;
|
||||
/**
|
||||
* Error flag for the observer
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Pending flag for the observer
|
||||
*/
|
||||
isPending?: boolean;
|
||||
/**
|
||||
* Last update time for data
|
||||
*/
|
||||
dataUpdatedAt?: number;
|
||||
/**
|
||||
* Last update time for error
|
||||
*/
|
||||
errorUpdatedAt?: number;
|
||||
/**
|
||||
* Consecutive failure count
|
||||
*/
|
||||
failureCount?: number;
|
||||
/**
|
||||
* Failure reason object
|
||||
*/
|
||||
failureReason?: TError;
|
||||
/**
|
||||
* Error count for the observer
|
||||
*/
|
||||
errorUpdateCount?: number;
|
||||
/**
|
||||
* Refetching flag
|
||||
*/
|
||||
isRefetching?: boolean;
|
||||
/**
|
||||
* Refetch error flag
|
||||
*/
|
||||
isRefetchError?: boolean;
|
||||
/**
|
||||
* Paused flag
|
||||
*/
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
// TANSTACK QUERY MOCK FACTORIES
|
||||
|
||||
/**
|
||||
* Create a mock query state for TanStack Query
|
||||
*/
|
||||
@@ -138,33 +226,53 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
|
||||
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||
}
|
||||
|
||||
// FONT STORE MOCKS
|
||||
|
||||
/**
|
||||
* Mock UnifiedFontStore state
|
||||
*/
|
||||
export interface MockFontStoreState {
|
||||
/** All cached fonts */
|
||||
/**
|
||||
* Map of mock fonts indexed by ID
|
||||
*/
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Current page */
|
||||
/**
|
||||
* Currently active page number
|
||||
*/
|
||||
page: number;
|
||||
/** Total pages available */
|
||||
/**
|
||||
* Total number of pages calculated from limit
|
||||
*/
|
||||
totalPages: number;
|
||||
/** Items per page */
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit: number;
|
||||
/** Total font count */
|
||||
/**
|
||||
* Total number of available fonts
|
||||
*/
|
||||
total: number;
|
||||
/** Loading state */
|
||||
/**
|
||||
* Store-level loading status
|
||||
*/
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
/**
|
||||
* Caught error object
|
||||
*/
|
||||
error: Error | null;
|
||||
/** Search query */
|
||||
/**
|
||||
* Mock search filter string
|
||||
*/
|
||||
searchQuery: string;
|
||||
/** Selected provider */
|
||||
/**
|
||||
* Mock provider filter selection
|
||||
*/
|
||||
provider: 'google' | 'fontshare' | 'all';
|
||||
/** Selected category */
|
||||
/**
|
||||
* Mock category filter selection
|
||||
*/
|
||||
category: string | null;
|
||||
/** Selected subset */
|
||||
/**
|
||||
* Mock subset filter selection
|
||||
*/
|
||||
subset: string | null;
|
||||
}
|
||||
|
||||
@@ -210,10 +318,12 @@ export function createMockFontStoreState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset font store states
|
||||
* Preset font store states for UI testing
|
||||
*/
|
||||
export const MOCK_FONT_STORE_STATES = {
|
||||
/** Initial loading state */
|
||||
/**
|
||||
* Initial loading state with no data
|
||||
*/
|
||||
loading: createMockFontStoreState({
|
||||
isLoading: true,
|
||||
fonts: {},
|
||||
@@ -221,7 +331,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
page: 1,
|
||||
}),
|
||||
|
||||
/** Empty state (no fonts found) */
|
||||
/**
|
||||
* State with no fonts matching filters
|
||||
*/
|
||||
empty: createMockFontStoreState({
|
||||
fonts: {},
|
||||
total: 0,
|
||||
@@ -229,7 +341,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** First page with fonts */
|
||||
/**
|
||||
* First page of results (10 items)
|
||||
*/
|
||||
firstPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
||||
@@ -241,7 +355,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Second page with fonts */
|
||||
/**
|
||||
* Second page of results (10 items)
|
||||
*/
|
||||
secondPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
||||
@@ -253,7 +369,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Last page with fonts */
|
||||
/**
|
||||
* Final page of results (5 items)
|
||||
*/
|
||||
lastPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
||||
@@ -265,7 +383,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Error state */
|
||||
/**
|
||||
* Terminal failure state
|
||||
*/
|
||||
error: createMockFontStoreState({
|
||||
fonts: {},
|
||||
error: new Error('Failed to load fonts'),
|
||||
@@ -274,7 +394,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** With search query */
|
||||
/**
|
||||
* State with active search query
|
||||
*/
|
||||
withSearch: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
||||
@@ -285,7 +407,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
searchQuery: 'Roboto',
|
||||
}),
|
||||
|
||||
/** Filtered by category */
|
||||
/**
|
||||
* State with active category filter
|
||||
*/
|
||||
filteredByCategory: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS)
|
||||
@@ -299,7 +423,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
category: 'serif',
|
||||
}),
|
||||
|
||||
/** Filtered by provider */
|
||||
/**
|
||||
* State with active provider filter
|
||||
*/
|
||||
filteredByProvider: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS)
|
||||
@@ -313,7 +439,9 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
provider: 'google',
|
||||
}),
|
||||
|
||||
/** Large dataset */
|
||||
/**
|
||||
* Large collection for performance testing (50 items)
|
||||
*/
|
||||
largeDataset: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
generateMockFonts(50).map(font => [font.id, font]),
|
||||
@@ -326,17 +454,30 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
}),
|
||||
};
|
||||
|
||||
// MOCK STORE OBJECT
|
||||
|
||||
/**
|
||||
* Create a mock store object that mimics TanStack Query behavior
|
||||
* Useful for components that subscribe to store properties
|
||||
*/
|
||||
export function createMockStore<T>(config: {
|
||||
/**
|
||||
* Reactive data payload
|
||||
*/
|
||||
data?: T;
|
||||
/**
|
||||
* Loading status flag
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Error status flag
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Catch-all error object
|
||||
*/
|
||||
error?: Error;
|
||||
/**
|
||||
* Background fetching flag
|
||||
*/
|
||||
isFetching?: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -348,24 +489,45 @@ export function createMockStore<T>(config: {
|
||||
} = config;
|
||||
|
||||
return {
|
||||
/**
|
||||
* Returns the active data payload
|
||||
*/
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
/**
|
||||
* True if initially loading
|
||||
*/
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
/**
|
||||
* True if last request failed
|
||||
*/
|
||||
get isError() {
|
||||
return isError;
|
||||
},
|
||||
/**
|
||||
* Returns the caught error object
|
||||
*/
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
/**
|
||||
* True if fetching in background
|
||||
*/
|
||||
get isFetching() {
|
||||
return isFetching;
|
||||
},
|
||||
/**
|
||||
* True if query is stable and has data
|
||||
*/
|
||||
get isSuccess() {
|
||||
return !isLoading && !isError && data !== undefined;
|
||||
},
|
||||
/**
|
||||
* Returns semantic status string
|
||||
*/
|
||||
get status() {
|
||||
if (isLoading) return 'pending';
|
||||
if (isError) return 'error';
|
||||
@@ -375,23 +537,29 @@ export function createMockStore<T>(config: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset mock stores
|
||||
* Preset mock stores for common UI states
|
||||
*/
|
||||
export const MOCK_STORES = {
|
||||
/** Font store in loading state */
|
||||
/**
|
||||
* Initial loading state
|
||||
*/
|
||||
loadingFontStore: createMockStore<UnifiedFont[]>({
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
}),
|
||||
|
||||
/** Font store with fonts loaded */
|
||||
/**
|
||||
* Successful data load state
|
||||
*/
|
||||
successFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: Object.values(UNIFIED_FONTS),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
|
||||
/** Font store with error */
|
||||
/**
|
||||
* API error state
|
||||
*/
|
||||
errorFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -399,7 +567,9 @@ export const MOCK_STORES = {
|
||||
error: new Error('Failed to load fonts'),
|
||||
}),
|
||||
|
||||
/** Font store with empty results */
|
||||
/**
|
||||
* Empty result set state
|
||||
*/
|
||||
emptyFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
@@ -414,36 +584,69 @@ export const MOCK_STORES = {
|
||||
const mockState = createMockFontStoreState(state);
|
||||
return {
|
||||
// State properties
|
||||
/**
|
||||
* Collection of mock fonts
|
||||
*/
|
||||
get fonts() {
|
||||
return mockState.fonts;
|
||||
},
|
||||
/**
|
||||
* Current mock page
|
||||
*/
|
||||
get page() {
|
||||
return mockState.page;
|
||||
},
|
||||
/**
|
||||
* Total mock pages
|
||||
*/
|
||||
get totalPages() {
|
||||
return mockState.totalPages;
|
||||
},
|
||||
/**
|
||||
* Mock items per page
|
||||
*/
|
||||
get limit() {
|
||||
return mockState.limit;
|
||||
},
|
||||
/**
|
||||
* Total mock items
|
||||
*/
|
||||
get total() {
|
||||
return mockState.total;
|
||||
},
|
||||
/**
|
||||
* Mock loading status
|
||||
*/
|
||||
get isLoading() {
|
||||
return mockState.isLoading;
|
||||
},
|
||||
/**
|
||||
* Mock error status
|
||||
*/
|
||||
get error() {
|
||||
return mockState.error;
|
||||
},
|
||||
/**
|
||||
* Mock search string
|
||||
*/
|
||||
get searchQuery() {
|
||||
return mockState.searchQuery;
|
||||
},
|
||||
/**
|
||||
* Mock provider filter
|
||||
*/
|
||||
get provider() {
|
||||
return mockState.provider;
|
||||
},
|
||||
/**
|
||||
* Mock category filter
|
||||
*/
|
||||
get category() {
|
||||
return mockState.category;
|
||||
},
|
||||
/**
|
||||
* Mock subset filter
|
||||
*/
|
||||
get subset() {
|
||||
return mockState.subset;
|
||||
},
|
||||
@@ -464,15 +667,45 @@ export const MOCK_STORES = {
|
||||
* Matches FontStore's public API for Storybook use
|
||||
*/
|
||||
fontStore: (config: {
|
||||
/**
|
||||
* Preset font list
|
||||
*/
|
||||
fonts?: UnifiedFont[];
|
||||
/**
|
||||
* Total item count
|
||||
*/
|
||||
total?: number;
|
||||
/**
|
||||
* Items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Pagination offset
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* Loading flag
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Fetching flag
|
||||
*/
|
||||
isFetching?: boolean;
|
||||
/**
|
||||
* Error flag
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Catch-all error object
|
||||
*/
|
||||
error?: Error | null;
|
||||
/**
|
||||
* Has more pages flag
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* Current page number
|
||||
*/
|
||||
page?: number;
|
||||
} = {}) => {
|
||||
const {
|
||||
@@ -495,27 +728,51 @@ export const MOCK_STORES = {
|
||||
|
||||
return {
|
||||
// State getters
|
||||
/**
|
||||
* Current mock parameters
|
||||
*/
|
||||
get params() {
|
||||
return state.params;
|
||||
},
|
||||
/**
|
||||
* Mock font list
|
||||
*/
|
||||
get fonts() {
|
||||
return mockFonts;
|
||||
},
|
||||
/**
|
||||
* Mock loading state
|
||||
*/
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
/**
|
||||
* Mock fetching state
|
||||
*/
|
||||
get isFetching() {
|
||||
return isFetching;
|
||||
},
|
||||
/**
|
||||
* Mock error state
|
||||
*/
|
||||
get isError() {
|
||||
return isError;
|
||||
},
|
||||
/**
|
||||
* Mock error object
|
||||
*/
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
/**
|
||||
* Mock empty state check
|
||||
*/
|
||||
get isEmpty() {
|
||||
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||
},
|
||||
/**
|
||||
* Mock pagination metadata
|
||||
*/
|
||||
get pagination() {
|
||||
return {
|
||||
total: mockTotal,
|
||||
@@ -527,18 +784,33 @@ export const MOCK_STORES = {
|
||||
};
|
||||
},
|
||||
// Category getters
|
||||
/**
|
||||
* Derived sans-serif filter
|
||||
*/
|
||||
get sansSerifFonts() {
|
||||
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||
},
|
||||
/**
|
||||
* Derived serif filter
|
||||
*/
|
||||
get serifFonts() {
|
||||
return mockFonts.filter(f => f.category === 'serif');
|
||||
},
|
||||
/**
|
||||
* Derived display filter
|
||||
*/
|
||||
get displayFonts() {
|
||||
return mockFonts.filter(f => f.category === 'display');
|
||||
},
|
||||
/**
|
||||
* Derived handwriting filter
|
||||
*/
|
||||
get handwritingFonts() {
|
||||
return mockFonts.filter(f => f.category === 'handwriting');
|
||||
},
|
||||
/**
|
||||
* Derived monospace filter
|
||||
*/
|
||||
get monospaceFonts() {
|
||||
return mockFonts.filter(f => f.category === 'monospace');
|
||||
},
|
||||
|
||||
@@ -13,15 +13,25 @@ import type {
|
||||
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
||||
*/
|
||||
export interface FontRowSizeResolverOptions {
|
||||
/** Returns the current fonts array. Index `i` corresponds to row `i`. */
|
||||
/**
|
||||
* Returns the current fonts array. Index `i` corresponds to row `i`.
|
||||
*/
|
||||
getFonts: () => UnifiedFont[];
|
||||
/** Returns the active font weight (e.g. 400). */
|
||||
/**
|
||||
* Returns the active font weight (e.g. 400).
|
||||
*/
|
||||
getWeight: () => number;
|
||||
/** Returns the preview text string. */
|
||||
/**
|
||||
* Returns the preview text string.
|
||||
*/
|
||||
getPreviewText: () => string;
|
||||
/** Returns the scroll container's inner width in pixels. Returns 0 before mount. */
|
||||
/**
|
||||
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
|
||||
*/
|
||||
getContainerWidth: () => number;
|
||||
/** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */
|
||||
/**
|
||||
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
|
||||
*/
|
||||
getFontSizePx: () => number;
|
||||
/**
|
||||
* Returns the computed line height in pixels.
|
||||
@@ -44,9 +54,13 @@ export interface FontRowSizeResolverOptions {
|
||||
* the content width is never over-estimated, keeping the height estimate safe.
|
||||
*/
|
||||
contentHorizontalPadding: number;
|
||||
/** Fixed height in pixels of chrome that is not text content (header bar, etc.). */
|
||||
/**
|
||||
* Fixed height in pixels of chrome that is not text content (header bar, etc.).
|
||||
*/
|
||||
chromeHeight: number;
|
||||
/** Height in pixels to return when the font is not loaded or container width is 0. */
|
||||
/**
|
||||
* Height in pixels to return when the font is not loaded or container width is 0.
|
||||
*/
|
||||
fallbackHeight: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '..';
|
||||
import type { ControlId } from '../types/typography';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './const/const';
|
||||
export * from './store';
|
||||
export * from './types';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||
import { FontFetchError } from './errors';
|
||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||
|
||||
// ── Fake collaborators ────────────────────────────────────────────────────────
|
||||
|
||||
class FakeBufferCache {
|
||||
async get(_url: string): Promise<ArrayBuffer> {
|
||||
return new ArrayBuffer(8);
|
||||
@@ -13,7 +13,9 @@ class FakeBufferCache {
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
|
||||
/**
|
||||
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
|
||||
*/
|
||||
class FailingBufferCache {
|
||||
async get(url: string): Promise<never> {
|
||||
throw new FontFetchError(url, new Error('network error'), 500);
|
||||
@@ -22,8 +24,6 @@ class FailingBufferCache {
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
||||
id,
|
||||
name: id,
|
||||
@@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AppliedFontsManager', () => {
|
||||
let manager: AppliedFontsManager;
|
||||
let eviction: FontEvictionPolicy;
|
||||
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── touch() ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('touch()', () => {
|
||||
it('queues and loads a new font', async () => {
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── queue processing ──────────────────────────────────────────────────────
|
||||
|
||||
describe('queue processing', () => {
|
||||
it('filters non-critical weights in data-saver mode', async () => {
|
||||
(navigator as any).connection = { saveData: true };
|
||||
@@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Phase 1 — fetch', () => {
|
||||
it('sets status to error on fetch failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Phase 2 — parse', () => {
|
||||
it('sets status to error on parse failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('#purgeUnused', () => {
|
||||
it('evicts fonts after TTL expires', async () => {
|
||||
manager.touch([makeConfig('ephemeral')]);
|
||||
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── destroy() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('clears all statuses', async () => {
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
|
||||
@@ -156,7 +156,9 @@ export class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||
/**
|
||||
* Returns true if data-saver mode is enabled (defers non-critical weights).
|
||||
*/
|
||||
#shouldDeferNonCritical(): boolean {
|
||||
return (navigator as any).connection?.saveData === true;
|
||||
}
|
||||
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
|
||||
const concurrency = getEffectiveConcurrency();
|
||||
const buffers = new Map<string, ArrayBuffer>();
|
||||
|
||||
// ==================== PHASE 1: Concurrent Fetching ====================
|
||||
// Fetch multiple font files in parallel since network I/O is non-blocking
|
||||
for (let i = 0; i < entries.length; i += concurrency) {
|
||||
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||
}
|
||||
|
||||
// ==================== PHASE 2: Sequential Parsing ====================
|
||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||
let lastYield = performance.now();
|
||||
@@ -279,7 +279,9 @@ export class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
||||
/**
|
||||
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
|
||||
*/
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
// Iterate through all tracked font keys
|
||||
@@ -307,7 +309,9 @@ export class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns current loading status for a font, or undefined if never requested. */
|
||||
/**
|
||||
* Returns current loading status for a font, or undefined if never requested.
|
||||
*/
|
||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||
try {
|
||||
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
||||
@@ -316,17 +320,23 @@ export class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
||||
/**
|
||||
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
|
||||
*/
|
||||
pin(id: string, weight: number, isVariable = false): void {
|
||||
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
||||
}
|
||||
|
||||
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
||||
/**
|
||||
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
|
||||
*/
|
||||
unpin(id: string, weight: number, isVariable = false): void {
|
||||
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
||||
}
|
||||
|
||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||
/**
|
||||
* Waits for all fonts to finish loading using document.fonts.ready.
|
||||
*/
|
||||
async ready(): Promise<void> {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -336,7 +346,9 @@ export class AppliedFontsManager {
|
||||
} catch { /* document unloaded */ }
|
||||
}
|
||||
|
||||
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||
/**
|
||||
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
|
||||
*/
|
||||
destroy() {
|
||||
// Abort all in-flight network requests
|
||||
this.#abortController.abort();
|
||||
@@ -375,5 +387,7 @@ export class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||
/**
|
||||
* Singleton instance — use throughout the application for unified font loading state.
|
||||
*/
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { FontFetchError } from '../../errors';
|
||||
import { FontBufferCache } from './FontBufferCache';
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
|
||||
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface FontBufferCacheOptions {
|
||||
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
|
||||
/**
|
||||
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
|
||||
*/
|
||||
fetcher?: Fetcher;
|
||||
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
|
||||
/**
|
||||
* Cache API cache name. Defaults to `'font-cache-v1'`.
|
||||
*/
|
||||
cacheName?: string;
|
||||
}
|
||||
|
||||
@@ -85,12 +89,16 @@ export class FontBufferCache {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
|
||||
/**
|
||||
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
|
||||
*/
|
||||
evict(url: string): void {
|
||||
this.#buffersByUrl.delete(url);
|
||||
}
|
||||
|
||||
/** Clears all in-memory cached buffers. */
|
||||
/**
|
||||
* Clears all in-memory cached buffers.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#buffersByUrl.clear();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
interface FontEvictionPolicyOptions {
|
||||
/** TTL in milliseconds. Defaults to 5 minutes. */
|
||||
/**
|
||||
* TTL in milliseconds. Defaults to 5 minutes.
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
@@ -28,12 +30,16 @@ export class FontEvictionPolicy {
|
||||
this.#usageTracker.set(key, now);
|
||||
}
|
||||
|
||||
/** Pins a font key so it is never evicted regardless of TTL. */
|
||||
/**
|
||||
* Pins a font key so it is never evicted regardless of TTL.
|
||||
*/
|
||||
pin(key: string): void {
|
||||
this.#pinnedFonts.add(key);
|
||||
}
|
||||
|
||||
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
|
||||
/**
|
||||
* Unpins a font key, allowing it to be evicted once its TTL expires.
|
||||
*/
|
||||
unpin(key: string): void {
|
||||
this.#pinnedFonts.delete(key);
|
||||
}
|
||||
@@ -57,18 +63,24 @@ export class FontEvictionPolicy {
|
||||
return now - lastUsed >= this.#TTL;
|
||||
}
|
||||
|
||||
/** Returns an iterator over all tracked font keys. */
|
||||
/**
|
||||
* Returns an iterator over all tracked font keys.
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
return this.#usageTracker.keys();
|
||||
}
|
||||
|
||||
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
|
||||
/**
|
||||
* Removes a font key from tracking. Called by the orchestrator after eviction.
|
||||
*/
|
||||
remove(key: string): void {
|
||||
this.#usageTracker.delete(key);
|
||||
this.#pinnedFonts.delete(key);
|
||||
}
|
||||
|
||||
/** Clears all usage timestamps and pinned keys. */
|
||||
/**
|
||||
* Clears all usage timestamps and pinned keys.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#usageTracker.clear();
|
||||
this.#pinnedFonts.clear();
|
||||
|
||||
@@ -34,22 +34,30 @@ export class FontLoadQueue {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Returns `true` if the key is currently in the queue. */
|
||||
/**
|
||||
* Returns `true` if the key is currently in the queue.
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this.#queue.has(key);
|
||||
}
|
||||
|
||||
/** Increments the retry count for a font key. */
|
||||
/**
|
||||
* Increments the retry count for a font key.
|
||||
*/
|
||||
incrementRetry(key: string): void {
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
|
||||
/**
|
||||
* Returns `true` if the font has reached or exceeded the maximum retry limit.
|
||||
*/
|
||||
isMaxRetriesReached(key: string): boolean {
|
||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
||||
}
|
||||
|
||||
/** Clears all queued fonts and resets all retry counts. */
|
||||
/**
|
||||
* Clears all queued fonts and resets all retry counts.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#queue.clear();
|
||||
this.#retryCounts.clear();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { FontParseError } from '../../errors';
|
||||
import { loadFont } from './loadFont';
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('FontStore', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('construction', () => {
|
||||
it('stores initial params', () => {
|
||||
const store = makeStore({ limit: 20 });
|
||||
@@ -90,7 +89,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('state after fetch', () => {
|
||||
it('exposes loaded fonts', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(7));
|
||||
@@ -129,7 +127,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('error states', () => {
|
||||
it('isError is false before any fetch', () => {
|
||||
const store = makeStore();
|
||||
@@ -178,7 +175,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('font accumulation', () => {
|
||||
it('replaces fonts when refetching the first page', async () => {
|
||||
const store = makeStore();
|
||||
@@ -212,7 +208,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('pagination state', () => {
|
||||
it('returns zero-value defaults before any fetch', () => {
|
||||
const store = makeStore();
|
||||
@@ -248,7 +243,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('setParams', () => {
|
||||
it('merges updates into existing params', () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
@@ -266,7 +260,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('filter change resets', () => {
|
||||
it('clears accumulated fonts when a filter changes', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
@@ -302,7 +295,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('staleTime in buildOptions', () => {
|
||||
it('is 5 minutes with no active filters', () => {
|
||||
const store = makeStore();
|
||||
@@ -331,7 +323,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('buildQueryKey', () => {
|
||||
it('omits empty-string params', () => {
|
||||
const store = makeStore();
|
||||
@@ -366,7 +357,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('destroy', () => {
|
||||
it('does not throw', () => {
|
||||
const store = makeStore();
|
||||
@@ -380,7 +370,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('refetch', () => {
|
||||
it('triggers a fetch', async () => {
|
||||
const store = makeStore();
|
||||
@@ -400,7 +389,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('nextPage', () => {
|
||||
let store: FontStore;
|
||||
|
||||
@@ -437,7 +425,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('prevPage and goToPage', () => {
|
||||
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
@@ -454,7 +441,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('prefetch', () => {
|
||||
it('triggers a fetch for the provided params', async () => {
|
||||
const store = makeStore();
|
||||
@@ -465,7 +451,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('getCachedData / setQueryData', () => {
|
||||
it('getCachedData returns undefined before any fetch', () => {
|
||||
queryClient.clear();
|
||||
@@ -497,7 +482,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('invalidate', () => {
|
||||
it('calls invalidateQueries', async () => {
|
||||
const store = await fetchedStore();
|
||||
@@ -508,7 +492,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('setLimit', () => {
|
||||
it('updates the limit param', () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
@@ -518,7 +501,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('filter shortcut methods', () => {
|
||||
let store: FontStore;
|
||||
|
||||
@@ -561,7 +543,6 @@ describe('FontStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('category getters', () => {
|
||||
it('each getter returns only fonts of that category', async () => {
|
||||
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { UnifiedFont } from '../../types';
|
||||
|
||||
type PageParam = { offset: number };
|
||||
|
||||
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
|
||||
/**
|
||||
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
|
||||
*/
|
||||
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||
|
||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||
@@ -44,34 +46,53 @@ export class FontStore {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Public state --
|
||||
|
||||
/**
|
||||
* Current filter and limit configuration
|
||||
*/
|
||||
get params(): FontStoreParams {
|
||||
return this.#params;
|
||||
}
|
||||
/**
|
||||
* Flattened list of all fonts loaded across all pages (reactive)
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
||||
}
|
||||
/**
|
||||
* True if the first page is currently being fetched
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.#result.isLoading;
|
||||
}
|
||||
/**
|
||||
* True if any background fetch is in progress (initial or pagination)
|
||||
*/
|
||||
get isFetching(): boolean {
|
||||
return this.#result.isFetching;
|
||||
}
|
||||
/**
|
||||
* True if the last fetch attempt resulted in an error
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.#result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last caught error from the query observer
|
||||
*/
|
||||
get error(): Error | null {
|
||||
return this.#result.error ?? null;
|
||||
}
|
||||
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
||||
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
||||
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
||||
/**
|
||||
* True if no fonts were found for the current filter criteria
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata derived from the last loaded page
|
||||
*/
|
||||
get pagination() {
|
||||
const pages = this.#result.data?.pages;
|
||||
const last = pages?.at(-1);
|
||||
@@ -95,37 +116,52 @@ export class FontStore {
|
||||
};
|
||||
}
|
||||
|
||||
// -- Lifecycle --
|
||||
|
||||
/**
|
||||
* Cleans up subscriptions and destroys the observer
|
||||
*/
|
||||
destroy() {
|
||||
this.#unsubscribe();
|
||||
this.#observer.destroy();
|
||||
}
|
||||
|
||||
// -- Param management --
|
||||
|
||||
/**
|
||||
* Merge new parameters into existing state and trigger a refetch
|
||||
*/
|
||||
setParams(updates: Partial<FontStoreParams>) {
|
||||
this.#params = { ...this.#params, ...updates };
|
||||
this.#observer.setOptions(this.buildOptions());
|
||||
}
|
||||
/**
|
||||
* Forcefully invalidate and refetch the current query from the network
|
||||
*/
|
||||
invalidate() {
|
||||
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||
}
|
||||
|
||||
// -- Async operations --
|
||||
|
||||
/**
|
||||
* Manually trigger a query refetch
|
||||
*/
|
||||
async refetch() {
|
||||
await this.#observer.refetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime the cache with data for a specific parameter set
|
||||
*/
|
||||
async prefetch(params: FontStoreParams) {
|
||||
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort any active network requests for this store
|
||||
*/
|
||||
cancel() {
|
||||
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve current font list from cache without triggering a fetch
|
||||
*/
|
||||
getCachedData(): UnifiedFont[] | undefined {
|
||||
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||
this.buildQueryKey(this.#params),
|
||||
@@ -134,6 +170,9 @@ export class FontStore {
|
||||
return data.pages.flatMap(p => p.fonts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually update the cached font data (useful for optimistic updates)
|
||||
*/
|
||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||
const key = this.buildQueryKey(this.#params);
|
||||
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||
@@ -164,56 +203,90 @@ export class FontStore {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Filter shortcuts --
|
||||
|
||||
/**
|
||||
* Shortcut to update provider filters
|
||||
*/
|
||||
setProviders(v: ProxyFontsParams['providers']) {
|
||||
this.setParams({ providers: v });
|
||||
}
|
||||
/**
|
||||
* Shortcut to update category filters
|
||||
*/
|
||||
setCategories(v: ProxyFontsParams['categories']) {
|
||||
this.setParams({ categories: v });
|
||||
}
|
||||
/**
|
||||
* Shortcut to update subset filters
|
||||
*/
|
||||
setSubsets(v: ProxyFontsParams['subsets']) {
|
||||
this.setParams({ subsets: v });
|
||||
}
|
||||
/**
|
||||
* Shortcut to update search query
|
||||
*/
|
||||
setSearch(v: string) {
|
||||
this.setParams({ q: v || undefined });
|
||||
}
|
||||
/**
|
||||
* Shortcut to update sort order
|
||||
*/
|
||||
setSort(v: ProxyFontsParams['sort']) {
|
||||
this.setParams({ sort: v });
|
||||
}
|
||||
|
||||
// -- Pagination navigation --
|
||||
|
||||
/**
|
||||
* Fetch the next page of results if available
|
||||
*/
|
||||
async nextPage(): Promise<void> {
|
||||
await this.#observer.fetchNextPage();
|
||||
}
|
||||
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
||||
goToPage(_page: number): void {} // no-op
|
||||
/**
|
||||
* Backward pagination (no-op: infinite scroll accumulates forward only)
|
||||
*/
|
||||
prevPage(): void {}
|
||||
/**
|
||||
* Jump to specific page (no-op for infinite scroll)
|
||||
*/
|
||||
goToPage(_page: number): void {}
|
||||
|
||||
/**
|
||||
* Update the number of items fetched per page
|
||||
*/
|
||||
setLimit(limit: number) {
|
||||
this.setParams({ limit });
|
||||
}
|
||||
|
||||
// -- Category views --
|
||||
|
||||
/**
|
||||
* Derived list of sans-serif fonts in the current set
|
||||
*/
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
/**
|
||||
* Derived list of serif fonts in the current set
|
||||
*/
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
/**
|
||||
* Derived list of display fonts in the current set
|
||||
*/
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
/**
|
||||
* Derived list of handwriting fonts in the current set
|
||||
*/
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
/**
|
||||
* Derived list of monospace fonts in the current set
|
||||
*/
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
|
||||
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
||||
|
||||
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||
const filtered: Record<string, any> = {};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Applied fonts manager
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Batch font store
|
||||
export { BatchFontStore } from './batchFontStore.svelte';
|
||||
|
||||
@@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic'
|
||||
* Combined filter state for font queries
|
||||
*/
|
||||
export interface FontFilters {
|
||||
/** Selected font providers */
|
||||
/**
|
||||
* Active font providers to fetch from
|
||||
*/
|
||||
providers: FontProvider[];
|
||||
/** Selected font categories */
|
||||
/**
|
||||
* Visual classifications (sans, serif, etc.)
|
||||
*/
|
||||
categories: FontCategory[];
|
||||
/** Selected character subsets */
|
||||
/**
|
||||
* Character sets required for the sample text
|
||||
*/
|
||||
subsets: FontSubset[];
|
||||
}
|
||||
|
||||
/** Filter group identifier */
|
||||
/**
|
||||
* Filter group identifier
|
||||
*/
|
||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||
|
||||
/** Filter type including search query */
|
||||
/**
|
||||
* Filter type including search query
|
||||
*/
|
||||
export type FilterType = FilterGroup | 'searchQuery';
|
||||
|
||||
/**
|
||||
@@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant;
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface FontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
/**
|
||||
* URL for the regular (400) weight
|
||||
*/
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
/**
|
||||
* URL for the italic (400) style
|
||||
*/
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
/**
|
||||
* URL for the bold (700) weight
|
||||
*/
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
/**
|
||||
* URL for the bold-italic (700) style
|
||||
*/
|
||||
boldItalic?: string;
|
||||
/** Additional variant mapping */
|
||||
/**
|
||||
* Mapping for all other numeric/custom variants
|
||||
*/
|
||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||
}
|
||||
|
||||
@@ -96,19 +116,24 @@ export interface FontStyleUrls {
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
/**
|
||||
* Epoch timestamp of last successful fetch
|
||||
*/
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
/**
|
||||
* Semantic version string from upstream
|
||||
*/
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
/**
|
||||
* ISO date string of last remote update
|
||||
*/
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
/**
|
||||
* Raw ranking integer from provider
|
||||
*/
|
||||
popularity?: number;
|
||||
/**
|
||||
* Normalized popularity score (0-100)
|
||||
*
|
||||
* Normalized across all fonts for consistent ranking
|
||||
* Higher values indicate more popular fonts
|
||||
* Normalized score (0-100) used for global sorting
|
||||
*/
|
||||
popularityScore?: number;
|
||||
}
|
||||
@@ -117,17 +142,38 @@ export interface FontMetadata {
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
/**
|
||||
* Whether the font supports fluid weight/width axes
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
/**
|
||||
* Definable axes for variable font interpolation
|
||||
*/
|
||||
axes?: Array<{
|
||||
/**
|
||||
* Human-readable axis name (e.g., 'Weight')
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* CSS property name (e.g., 'wght')
|
||||
*/
|
||||
property: string;
|
||||
/**
|
||||
* Default numeric value for the axis
|
||||
*/
|
||||
default: number;
|
||||
/**
|
||||
* Minimum inclusive bound
|
||||
*/
|
||||
min: number;
|
||||
/**
|
||||
* Maximum inclusive bound
|
||||
*/
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
/**
|
||||
* Descriptive keywords for search indexing
|
||||
*/
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -138,29 +184,44 @@ export interface FontFeatures {
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
/**
|
||||
* Unique ID (family name for Google, slug for Fontshare)
|
||||
*/
|
||||
id: string;
|
||||
/** Font display name */
|
||||
/**
|
||||
* Canonical family name for CSS font-family
|
||||
*/
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
/**
|
||||
* Upstream data source
|
||||
*/
|
||||
provider: FontProvider;
|
||||
/**
|
||||
* Provider badge display name
|
||||
*
|
||||
* Human-readable provider name for UI display
|
||||
* e.g., "Google Fonts" or "Fontshare"
|
||||
* Display label for provider badges
|
||||
*/
|
||||
providerBadge?: string;
|
||||
/** Font category classification */
|
||||
/**
|
||||
* Primary typographic category
|
||||
*/
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
/**
|
||||
* All supported character sets
|
||||
*/
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
/**
|
||||
* List of available weights and styles
|
||||
*/
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
/**
|
||||
* Remote assets for font loading
|
||||
*/
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
/**
|
||||
* Technical metadata and rankings
|
||||
*/
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
/**
|
||||
* Variable font details and tags
|
||||
*/
|
||||
features: FontFeatures;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SINGLE EXPORT POINT
|
||||
* ============================================================================
|
||||
*
|
||||
* This is the single export point for all Font types.
|
||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
||||
*/
|
||||
|
||||
// Font domain and model types
|
||||
export type {
|
||||
FilterGroup,
|
||||
@@ -33,3 +24,4 @@ export type {
|
||||
} from './store';
|
||||
|
||||
export * from './store/appliedFonts';
|
||||
export * from './typography';
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* STORE TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
@@ -12,37 +6,55 @@ import type {
|
||||
} from './font';
|
||||
|
||||
/**
|
||||
* Font collection state
|
||||
* Global state for the local font collection
|
||||
*/
|
||||
export interface FontCollectionState {
|
||||
/** All cached fonts */
|
||||
/**
|
||||
* Map of cached fonts indexed by their unique family ID
|
||||
*/
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Active filters */
|
||||
/**
|
||||
* Set of active user-defined filters
|
||||
*/
|
||||
filters: FontCollectionFilters;
|
||||
/** Sort configuration */
|
||||
/**
|
||||
* Current sorting parameters for the display list
|
||||
*/
|
||||
sort: FontCollectionSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection filters
|
||||
* Filter configuration for narrow collections
|
||||
*/
|
||||
export interface FontCollectionFilters {
|
||||
/** Search query */
|
||||
/**
|
||||
* Partial family name to match against
|
||||
*/
|
||||
searchQuery: string;
|
||||
/** Filter by providers */
|
||||
/**
|
||||
* Data sources (Google, Fontshare) to include
|
||||
*/
|
||||
providers?: FontProvider[];
|
||||
/** Filter by categories */
|
||||
/**
|
||||
* Typographic categories (Serif, Sans, etc.) to include
|
||||
*/
|
||||
categories?: FontCategory[];
|
||||
/** Filter by subsets */
|
||||
/**
|
||||
* Character sets (Latin, Cyrillic, etc.) to include
|
||||
*/
|
||||
subsets?: FontSubset[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection sort configuration
|
||||
* Ordering configuration for the font list
|
||||
*/
|
||||
export interface FontCollectionSort {
|
||||
/** Sort field */
|
||||
/**
|
||||
* The font property to order by
|
||||
*/
|
||||
field: 'name' | 'popularity' | 'category';
|
||||
/** Sort direction */
|
||||
/**
|
||||
* The sort order (Ascending or Descending)
|
||||
*/
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
1
src/entities/Font/model/types/typography.ts
Normal file
1
src/entities/Font/model/types/typography.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
@@ -10,6 +10,7 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
} from '../../model';
|
||||
@@ -36,7 +37,7 @@ interface Props {
|
||||
|
||||
let {
|
||||
font,
|
||||
weight = 400,
|
||||
weight = DEFAULT_FONT_WEIGHT,
|
||||
className,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
} from '../../model';
|
||||
import { fontStore } from '../../model/store';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
@@ -53,30 +53,42 @@ const isLoading = $derived(
|
||||
fontStore.isFetching || fontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
|
||||
if (url) {
|
||||
configs.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url,
|
||||
isVariable: item.features?.isVariable,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-register fonts with the manager
|
||||
appliedFontsManager.touch(configs);
|
||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
visibleFonts = items;
|
||||
// Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
onVisibleItemsChange?.(items);
|
||||
}
|
||||
|
||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||
$effect(() => {
|
||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
if (!url) return [];
|
||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||
});
|
||||
if (configs.length > 0) {
|
||||
appliedFontsManager.touch(configs);
|
||||
}
|
||||
});
|
||||
|
||||
// Pin visible fonts so the eviction policy never removes on-screen entries.
|
||||
// Cleanup captures the snapshot values, so a weight change unpins the old
|
||||
// weight before pinning the new one.
|
||||
$effect(() => {
|
||||
const w = weight;
|
||||
const fonts = visibleFonts;
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
|
||||
@@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user';
|
||||
*/
|
||||
class ThemeManager {
|
||||
// Private reactive state
|
||||
/** Current theme value ('light' or 'dark') */
|
||||
/**
|
||||
* Current theme value ('light' or 'dark')
|
||||
*/
|
||||
#theme = $state<Theme>('light');
|
||||
/** Whether theme is controlled by user or follows system */
|
||||
/**
|
||||
* Whether theme is controlled by user or follows system
|
||||
*/
|
||||
#source = $state<ThemeSource>('system');
|
||||
/** MediaQueryList for detecting system theme changes */
|
||||
/**
|
||||
* MediaQueryList for detecting system theme changes
|
||||
*/
|
||||
#mediaQuery: MediaQueryList | null = null;
|
||||
/** Persistent storage for user's theme preference */
|
||||
/**
|
||||
* Persistent storage for user's theme preference
|
||||
*/
|
||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||
/** Bound handler for system theme change events */
|
||||
/**
|
||||
* Bound handler for system theme change events
|
||||
*/
|
||||
#systemChangeHandler = this.#onSystemChange.bind(this);
|
||||
|
||||
constructor() {
|
||||
@@ -64,22 +74,30 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Current theme value */
|
||||
/**
|
||||
* Current theme value
|
||||
*/
|
||||
get value(): Theme {
|
||||
return this.#theme;
|
||||
}
|
||||
|
||||
/** Source of current theme ('system' or 'user') */
|
||||
/**
|
||||
* Source of current theme ('system' or 'user')
|
||||
*/
|
||||
get source(): ThemeSource {
|
||||
return this.#source;
|
||||
}
|
||||
|
||||
/** Whether dark theme is active */
|
||||
/**
|
||||
* Whether dark theme is active
|
||||
*/
|
||||
get isDark(): boolean {
|
||||
return this.#theme === 'dark';
|
||||
}
|
||||
|
||||
/** Whether theme is controlled by user (not following system) */
|
||||
/**
|
||||
* Whether theme is controlled by user (not following system)
|
||||
*/
|
||||
get isUserControlled(): boolean {
|
||||
return this.#source === 'user';
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Mock MediaQueryListEvent for system theme change simulations
|
||||
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
||||
// ============================================================
|
||||
|
||||
class MockMediaQueryListEvent extends Event {
|
||||
matches: boolean;
|
||||
@@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NOW IT'S SAFE TO IMPORT
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
|
||||
@@ -35,7 +35,6 @@ const { Story } = defineMeta({
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
Divider,
|
||||
Footnote,
|
||||
Stat,
|
||||
StatGroup,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
@@ -37,11 +36,6 @@ interface Props {
|
||||
|
||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
|
||||
// Adjust the property name to match your UnifiedFont type
|
||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||
|
||||
@@ -52,10 +46,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${fontSize}PX` },
|
||||
{ label: 'WGT', value: `${fontWeight}` },
|
||||
{ label: 'LH', value: lineHeight?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${letterSpacing}` },
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -65,7 +59,7 @@ const stats = $derived([
|
||||
group relative
|
||||
w-full h-full
|
||||
bg-paper dark:bg-dark-card
|
||||
border border-black/5 dark:border-white/10
|
||||
border border-subtle
|
||||
hover:border-brand dark:hover:border-brand
|
||||
hover:shadow-brand/10
|
||||
hover:shadow-[5px_5px_0px_0px]
|
||||
@@ -75,20 +69,20 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
bg-paper dark:bg-dark-card
|
||||
"
|
||||
>
|
||||
<!-- Left: index · name · type badge · provider badge -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
||||
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
||||
<span class="font-mono text-2xs tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<Divider orientation="vertical" class="h-3 shrink-0" />
|
||||
@@ -100,14 +94,14 @@ const stats = $derived([
|
||||
</span>
|
||||
|
||||
{#if fontType}
|
||||
<Badge size="xs" variant="default" class="text-nowrap font-mono">
|
||||
<Badge size="xs" variant="default" nowrap>
|
||||
{fontType}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<!-- Provider badge -->
|
||||
{#if providerBadge}
|
||||
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
|
||||
<Badge size="xs" variant="default" nowrap data-provider={font.provider}>
|
||||
{providerBadge}
|
||||
</Badge>
|
||||
{/if}
|
||||
@@ -140,20 +134,20 @@ const stats = $derived([
|
||||
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} weight={fontWeight}>
|
||||
<FontApplicator {font} weight={typographySettingsStore.weight}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
{fontSize}
|
||||
{lineHeight}
|
||||
{letterSpacing}
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
{stat.label}:{stat.value}
|
||||
</Footnote>
|
||||
{#if i < stats.length - 1}
|
||||
|
||||
@@ -15,19 +15,29 @@ const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
||||
* Filter metadata type from backend
|
||||
*/
|
||||
export interface FilterMetadata {
|
||||
/** Filter ID (e.g., "providers", "categories", "subsets") */
|
||||
/**
|
||||
* Filter ID (e.g., "providers", "categories", "subsets")
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
|
||||
/**
|
||||
* Display name (e.g., "Font Providers", "Categories", "Character Subsets")
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/** Filter description */
|
||||
/**
|
||||
* Filter description
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/** Filter type */
|
||||
/**
|
||||
* Filter type
|
||||
*/
|
||||
type: 'enum' | 'string' | 'array';
|
||||
|
||||
/** Available filter options */
|
||||
/**
|
||||
* Available filter options
|
||||
*/
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
@@ -35,16 +45,24 @@ export interface FilterMetadata {
|
||||
* Filter option type
|
||||
*/
|
||||
export interface FilterOption {
|
||||
/** Option ID (e.g., "google", "serif", "latin") */
|
||||
/**
|
||||
* Option ID (e.g., "google", "serif", "latin")
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
|
||||
/**
|
||||
* Display name (e.g., "Google Fonts", "Serif", "Latin")
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/** Option value (e.g., "google", "serif", "latin") */
|
||||
/**
|
||||
* Option value (e.g., "google", "serif", "latin")
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/** Number of fonts with this value */
|
||||
/**
|
||||
* Number of fonts with this value
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -52,7 +70,9 @@ export interface FilterOption {
|
||||
* Proxy filters API response
|
||||
*/
|
||||
export interface ProxyFiltersResponse {
|
||||
/** Array of filter metadata */
|
||||
/**
|
||||
* Array of filter metadata
|
||||
*/
|
||||
filters: FilterMetadata[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,56 @@
|
||||
export type {
|
||||
/**
|
||||
* Top-level configuration for all filters
|
||||
*/
|
||||
FilterConfig,
|
||||
/**
|
||||
* Configuration for a single grouping of filter properties
|
||||
*/
|
||||
FilterGroupConfig,
|
||||
} from './types/filter';
|
||||
|
||||
export { filtersStore } from './state/filters.svelte';
|
||||
export { filterManager } from './state/manager.svelte';
|
||||
|
||||
/**
|
||||
* Global reactive filter state
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Low-level property selection store
|
||||
*/
|
||||
filtersStore,
|
||||
} from './state/filters.svelte';
|
||||
|
||||
/**
|
||||
* Main filter controller
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* High-level manager for syncing search and filters
|
||||
*/
|
||||
filterManager,
|
||||
} from './state/manager.svelte';
|
||||
|
||||
/**
|
||||
* Sorting logic
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Map of human-readable labels to API sort keys
|
||||
*/
|
||||
SORT_MAP,
|
||||
/**
|
||||
* List of all available sort options for the UI
|
||||
*/
|
||||
SORT_OPTIONS,
|
||||
/**
|
||||
* Valid sort key values
|
||||
*/
|
||||
type SortApiValue,
|
||||
/**
|
||||
* UI model for a single sort option
|
||||
*/
|
||||
type SortOption,
|
||||
/**
|
||||
* Reactive store for the current sort selection
|
||||
*/
|
||||
sortStore,
|
||||
} from './store/sortStore.svelte';
|
||||
|
||||
@@ -32,13 +32,19 @@ import {
|
||||
* Provides reactive access to filter data
|
||||
*/
|
||||
class FiltersStore {
|
||||
/** TanStack Query result state */
|
||||
/**
|
||||
* TanStack Query result state
|
||||
*/
|
||||
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
||||
|
||||
/** TanStack Query observer instance */
|
||||
/**
|
||||
* TanStack Query observer instance
|
||||
*/
|
||||
protected observer: QueryObserver<FilterMetadata[], Error>;
|
||||
|
||||
/** Shared query client */
|
||||
/**
|
||||
* Shared query client
|
||||
*/
|
||||
protected qc = queryClient;
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,17 +21,23 @@ function createSortStore(initial: SortOption = 'Popularity') {
|
||||
let current = $state<SortOption>(initial);
|
||||
|
||||
return {
|
||||
/** Current display label (e.g. 'Popularity') */
|
||||
/**
|
||||
* Current display label (e.g. 'Popularity')
|
||||
*/
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
|
||||
/** Mapped API value (e.g. 'popularity') */
|
||||
/**
|
||||
* Mapped API value (e.g. 'popularity')
|
||||
*/
|
||||
get apiValue(): SortApiValue {
|
||||
return SORT_MAP[current];
|
||||
},
|
||||
|
||||
/** Set the active sort option by its display label */
|
||||
/**
|
||||
* Set the active sort option by its display label
|
||||
*/
|
||||
set(option: SortOption) {
|
||||
current = option;
|
||||
},
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
export interface FilterGroupConfig<TValue extends string> {
|
||||
/**
|
||||
* Unique identifier for the filter group (e.g. 'categories')
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Human-readable label displayed in the UI header
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* List of toggleable properties within this group
|
||||
*/
|
||||
properties: Property<TValue>[];
|
||||
}
|
||||
|
||||
export interface FilterConfig<TValue extends string> {
|
||||
/**
|
||||
* Optional string to filter results by name
|
||||
*/
|
||||
queryValue?: string;
|
||||
/**
|
||||
* Collection of filter groups to display
|
||||
*/
|
||||
groups: FilterGroupConfig<TValue>[];
|
||||
}
|
||||
|
||||
@@ -61,13 +61,10 @@ function handleReset() {
|
||||
{#each SORT_OPTIONS as option}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
active={sortStore.value === option}
|
||||
onclick={() => sortStore.set(option)}
|
||||
class={cn(
|
||||
'font-bold uppercase tracking-wide font-primary, px-0',
|
||||
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
|
||||
)}
|
||||
class="tracking-wide px-0"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
@@ -78,12 +75,9 @@ function handleReset() {
|
||||
<!-- Reset_Filters -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
onclick={handleReset}
|
||||
class={cn(
|
||||
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
|
||||
isMobileOrTabletPortrait && 'px-0',
|
||||
)}
|
||||
class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
iconPosition="left"
|
||||
>
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './lib';
|
||||
export { typographySettingsStore } from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './controlManager/controlManager.svelte';
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './settingsManager/settingsManager.svelte';
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* when displaying/editing, but the base size is what's stored.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
@@ -19,20 +26,16 @@ import {
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '../../model';
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
/**
|
||||
* A control with its instance
|
||||
* A control with its associated instance
|
||||
*/
|
||||
export interface Control extends ControlOnlyFields<ControlId> {
|
||||
/**
|
||||
* The reactive typography control instance
|
||||
*/
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
@@ -40,9 +43,21 @@ export interface Control extends ControlOnlyFields<ControlId> {
|
||||
* Storage schema for typography settings
|
||||
*/
|
||||
export interface TypographySettings {
|
||||
/**
|
||||
* Base font size (User preference, unscaled)
|
||||
*/
|
||||
fontSize: number;
|
||||
/**
|
||||
* Numeric font weight (100-900)
|
||||
*/
|
||||
fontWeight: number;
|
||||
/**
|
||||
* Line height multiplier (e.g. 1.5)
|
||||
*/
|
||||
lineHeight: number;
|
||||
/**
|
||||
* Letter spacing in em/px
|
||||
*/
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
@@ -52,14 +67,22 @@ export interface TypographySettings {
|
||||
* Manages multiple typography controls with persistent storage and
|
||||
* responsive scaling support for font size.
|
||||
*/
|
||||
export class TypographyControlManager {
|
||||
/** Map of controls keyed by ID */
|
||||
export class TypographySettingsManager {
|
||||
/**
|
||||
* Internal map of reactive controls keyed by their identifier
|
||||
*/
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
/** Responsive multiplier for font size display */
|
||||
/**
|
||||
* Global multiplier for responsive font size scaling
|
||||
*/
|
||||
#multiplier = $state(1);
|
||||
/** Persistent storage for settings */
|
||||
/**
|
||||
* LocalStorage-backed storage for persistence
|
||||
*/
|
||||
#storage: PersistentStore<TypographySettings>;
|
||||
/** Base font size (user preference, unscaled) */
|
||||
/**
|
||||
* The underlying font size before responsive scaling is applied
|
||||
*/
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
@@ -131,16 +154,15 @@ export class TypographyControlManager {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Current multiplier for responsive scaling */
|
||||
/**
|
||||
* Active scaling factor for the rendered font size
|
||||
*/
|
||||
get multiplier() {
|
||||
return this.#multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the multiplier and update font size display
|
||||
*
|
||||
* When multiplier changes, the font size control's display value
|
||||
* is updated to reflect the new scale while preserving base size.
|
||||
* Updates the multiplier and recalculates dependent control values
|
||||
*/
|
||||
set multiplier(value: number) {
|
||||
if (this.#multiplier === value) return;
|
||||
@@ -154,14 +176,15 @@ export class TypographyControlManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* The scaled size for CSS usage
|
||||
* Returns baseSize * multiplier for actual rendering
|
||||
* The actual pixel value for CSS font-size (baseSize * multiplier)
|
||||
*/
|
||||
get renderedSize() {
|
||||
return this.#baseSize * this.#multiplier;
|
||||
}
|
||||
|
||||
/** The base size (User Preference) */
|
||||
/**
|
||||
* The raw font size preference before scaling
|
||||
*/
|
||||
get baseSize() {
|
||||
return this.#baseSize;
|
||||
}
|
||||
@@ -173,45 +196,63 @@ export class TypographyControlManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for controls
|
||||
* List of all managed typography controls
|
||||
*/
|
||||
get controls() {
|
||||
return Array.from(this.#controls.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive instance for weight manipulation
|
||||
*/
|
||||
get weightControl() {
|
||||
return this.#controls.get('font_weight')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive instance for size manipulation
|
||||
*/
|
||||
get sizeControl() {
|
||||
return this.#controls.get('font_size')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive instance for line-height manipulation
|
||||
*/
|
||||
get heightControl() {
|
||||
return this.#controls.get('line_height')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive instance for letter-spacing manipulation
|
||||
*/
|
||||
get spacingControl() {
|
||||
return this.#controls.get('letter_spacing')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for values (besides font-size)
|
||||
* Current numeric font weight (reactive)
|
||||
*/
|
||||
get weight() {
|
||||
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current numeric line height (reactive)
|
||||
*/
|
||||
get height() {
|
||||
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current numeric letter spacing (reactive)
|
||||
*/
|
||||
get spacing() {
|
||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all controls to default values
|
||||
* Reset all controls to project-defined defaults
|
||||
*/
|
||||
reset() {
|
||||
this.#storage.clear();
|
||||
@@ -242,7 +283,7 @@ export class TypographyControlManager {
|
||||
* @param storageId - Persistent storage identifier
|
||||
* @returns Typography control manager instance
|
||||
*/
|
||||
export function createTypographyControlManager(
|
||||
export function createTypographySettingsManager(
|
||||
configs: ControlModel<ControlId>[],
|
||||
storageId: string = 'glyphdiff:typography',
|
||||
) {
|
||||
@@ -252,5 +293,5 @@ export function createTypographyControlManager(
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
return new TypographyControlManager(configs, storage);
|
||||
return new TypographySettingsManager(configs, storage);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -8,21 +16,14 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '../../model';
|
||||
import {
|
||||
TypographyControlManager,
|
||||
type TypographySettings,
|
||||
} from './controlManager.svelte';
|
||||
TypographySettingsManager,
|
||||
} from './settingsManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for TypographyControlManager
|
||||
* Test Strategy for TypographySettingsManager
|
||||
*
|
||||
* This test suite validates the TypographyControlManager state management logic.
|
||||
* This test suite validates the TypographySettingsManager state management logic.
|
||||
* 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
|
||||
@@ -45,7 +46,7 @@ async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('TypographyControlManager - Unit Tests', () => {
|
||||
describe('TypographySettingsManager - Unit Tests', () => {
|
||||
let mockStorage: TypographySettings;
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
@@ -85,7 +86,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('creates manager with default values from storage', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -105,7 +106,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -117,7 +118,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -126,7 +127,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns all controls via controls getter', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -142,7 +143,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns individual controls via specific getters', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -160,7 +161,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('control instances have expected interface', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -179,7 +180,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Multiplier System', () => {
|
||||
it('has default multiplier of 1', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -188,7 +189,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates multiplier when set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -201,7 +202,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('does not update multiplier if set to same value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -217,7 +218,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -241,7 +242,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates font size control display value when multiplier increases', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -262,7 +263,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Base Size Setter', () => {
|
||||
it('updates baseSize when set directly', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -273,7 +274,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates size control value when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -284,7 +285,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('applies multiplier to size control when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -298,7 +299,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Rendered Size Calculation', () => {
|
||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -307,7 +308,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when multiplier changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -320,7 +321,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when baseSize changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -340,7 +341,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
// proxy effect behavior should be tested in E2E tests.
|
||||
|
||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -355,7 +356,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates baseSize via direct setter (synchronous)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -380,7 +381,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -393,7 +394,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -409,7 +410,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -422,7 +423,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -434,7 +435,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -448,7 +449,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Control Value Getters', () => {
|
||||
it('returns current weight value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -460,7 +461,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current height value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -472,7 +473,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current spacing value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -485,7 +486,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
it('returns default value when control is not found', () => {
|
||||
// Create a manager with empty configs (no controls)
|
||||
const manager = new TypographyControlManager([], mockPersistentStore);
|
||||
const manager = new TypographySettingsManager([], mockPersistentStore);
|
||||
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
@@ -503,7 +504,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -536,7 +537,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
clear: clearSpy,
|
||||
};
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -547,7 +548,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('respects multiplier when resetting font size control', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -565,7 +566,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Complex Scenarios', () => {
|
||||
it('handles changing multiplier then modifying baseSize', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -586,7 +587,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('maintains correct renderedSize throughout changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -608,7 +609,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles multiple control changes in sequence', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -633,7 +634,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -645,7 +646,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles very small multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -658,7 +659,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles large base size with multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -671,7 +672,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles floating point precision in multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -690,7 +691,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control methods (increase/decrease)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -704,7 +705,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control boundary conditions', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -1,24 +1 @@
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
} from './state/manager.svelte';
|
||||
export { typographySettingsStore } from './state/typographySettingsStore';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createTypographyControlManager } from '../../lib';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
||||
import { createTypographySettingsManager } from '../../lib';
|
||||
|
||||
export const typographySettingsStore = createTypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
'glyphdiff:comparison:typography',
|
||||
);
|
||||
@@ -6,10 +6,14 @@
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Slider,
|
||||
@@ -20,12 +24,7 @@ import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../../model';
|
||||
import { typographySettingsStore } from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -52,16 +51,16 @@ $effect(() => {
|
||||
if (!responsive) return;
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -80,7 +79,7 @@ $effect(() => {
|
||||
'transition-colors duration-150',
|
||||
'hover:bg-white/50 dark:hover:bg-white/5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
|
||||
isOpen && 'bg-paper dark:bg-dark-card border-subtle shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -97,7 +96,7 @@ $effect(() => {
|
||||
class={cn(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'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',
|
||||
@@ -110,11 +109,11 @@ $effect(() => {
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Settings2Icon size={12} class="text-swiss-red" />
|
||||
<span
|
||||
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
||||
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
||||
>
|
||||
CONTROLS
|
||||
</span>
|
||||
@@ -133,7 +132,7 @@ $effect(() => {
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
{#each typographySettingsStore.controls as control (control.id)}
|
||||
<ControlGroup label={control.controlLabel ?? ''}>
|
||||
<Slider
|
||||
bind:value={control.instance.value}
|
||||
@@ -155,26 +154,26 @@ $effect(() => {
|
||||
class={cn(
|
||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'border border-subtle',
|
||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
||||
)}
|
||||
>
|
||||
<!-- Header: icon + label -->
|
||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 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 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||
<Settings2Icon
|
||||
size={14}
|
||||
class="text-swiss-red"
|
||||
/>
|
||||
<span
|
||||
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||
>
|
||||
GLOBAL_CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls with dividers between each -->
|
||||
{#each controlManager.controls as control, i (control.id)}
|
||||
{#each typographySettingsStore.controls as control, i (control.id)}
|
||||
{#if i > 0}
|
||||
<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}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Application entry point
|
||||
*
|
||||
* Mounts the main App component to the DOM and initializes
|
||||
* global styles.
|
||||
*/
|
||||
import App from '$app/App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
import '$app/styles/app.css';
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
Description: The main page component of the application.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import { ComparisonView } from '$widgets/ComparisonView';
|
||||
import { FontSearchSection } from '$widgets/FontSearch';
|
||||
import { SampleListSection } from '$widgets/SampleList';
|
||||
import { cubicIn } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
@@ -18,8 +15,4 @@ import { fade } from 'svelte/transition';
|
||||
<section class="w-auto">
|
||||
<ComparisonView />
|
||||
</section>
|
||||
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
|
||||
<FontSearchSection />
|
||||
<SampleListSection index={1} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -41,10 +41,14 @@ export class ApiError extends Error {
|
||||
* @param response - Original fetch Response object
|
||||
*/
|
||||
constructor(
|
||||
/** HTTP status code */
|
||||
/**
|
||||
* HTTP status code
|
||||
*/
|
||||
public status: number,
|
||||
message: string,
|
||||
/** Original Response object for inspection */
|
||||
/**
|
||||
* Original Response object for inspection
|
||||
*/
|
||||
public response?: Response,
|
||||
) {
|
||||
super(message);
|
||||
|
||||
@@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/** Data remains fresh for 5 minutes after fetch */
|
||||
/**
|
||||
* Data remains fresh for 5 minutes after fetch
|
||||
*/
|
||||
staleTime: 5 * 60 * 1000,
|
||||
/** Unused cache entries are removed after 10 minutes */
|
||||
/**
|
||||
* Unused cache entries are removed after 10 minutes
|
||||
*/
|
||||
gcTime: 10 * 60 * 1000,
|
||||
/** Don't refetch when window regains focus */
|
||||
/**
|
||||
* Don't refetch when window regains focus
|
||||
*/
|
||||
refetchOnWindowFocus: false,
|
||||
/** Refetch on mount if data is stale */
|
||||
/**
|
||||
* Refetch on mount if data is stale
|
||||
*/
|
||||
refetchOnMount: true,
|
||||
/** Retry failed requests up to 3 times */
|
||||
/**
|
||||
* Retry failed requests up to 3 times
|
||||
*/
|
||||
retry: 3,
|
||||
/**
|
||||
* Exponential backoff for retries
|
||||
|
||||
@@ -3,21 +3,35 @@
|
||||
* Ensures consistent serialization for batch requests by sorting IDs.
|
||||
*/
|
||||
export const fontKeys = {
|
||||
/** Base key for all font queries */
|
||||
/**
|
||||
* Base key for all font queries
|
||||
*/
|
||||
all: ['fonts'] as const,
|
||||
|
||||
/** Keys for font list queries */
|
||||
/**
|
||||
* Keys for font list queries
|
||||
*/
|
||||
lists: () => [...fontKeys.all, 'list'] as const,
|
||||
/** Specific font list key with filter parameters */
|
||||
/**
|
||||
* Specific font list key with filter parameters
|
||||
*/
|
||||
list: (params: object) => [...fontKeys.lists(), params] as const,
|
||||
|
||||
/** Keys for font batch queries */
|
||||
/**
|
||||
* Keys for font batch queries
|
||||
*/
|
||||
batches: () => [...fontKeys.all, 'batch'] as const,
|
||||
/** Specific batch key, sorted for stability */
|
||||
/**
|
||||
* Specific batch key, sorted for stability
|
||||
*/
|
||||
batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const,
|
||||
|
||||
/** Keys for font detail queries */
|
||||
/**
|
||||
* Keys for font detail queries
|
||||
*/
|
||||
details: () => [...fontKeys.all, 'detail'] as const,
|
||||
/** Specific font detail key by ID */
|
||||
/**
|
||||
* Specific font detail key by ID
|
||||
*/
|
||||
detail: (id: string) => [...fontKeys.details(), id] as const,
|
||||
} as const;
|
||||
|
||||
@@ -12,20 +12,37 @@ import {
|
||||
* each font's actual advance widths independently.
|
||||
*/
|
||||
export interface ComparisonLine {
|
||||
/** Full text of this line as returned by pretext. */
|
||||
/**
|
||||
* Full text of this line as returned by pretext.
|
||||
*/
|
||||
text: string;
|
||||
/** Rendered width of this line in pixels — maximum across font A and font B. */
|
||||
/**
|
||||
* Rendered width of this line in pixels — maximum across font A and font B.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Individual character metadata for both fonts in this line
|
||||
*/
|
||||
chars: Array<{
|
||||
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||
/**
|
||||
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
|
||||
*/
|
||||
char: string;
|
||||
/** X offset from the start of the line in font A, in pixels. */
|
||||
/**
|
||||
* X offset from the start of the line in font A, in pixels.
|
||||
*/
|
||||
xA: number;
|
||||
/** Advance width of this grapheme in font A, in pixels. */
|
||||
/**
|
||||
* Advance width of this grapheme in font A, in pixels.
|
||||
*/
|
||||
widthA: number;
|
||||
/** X offset from the start of the line in font B, in pixels. */
|
||||
/**
|
||||
* X offset from the start of the line in font B, in pixels.
|
||||
*/
|
||||
xB: number;
|
||||
/** Advance width of this grapheme in font B, in pixels. */
|
||||
/**
|
||||
* Advance width of this grapheme in font B, in pixels.
|
||||
*/
|
||||
widthB: number;
|
||||
}>;
|
||||
}
|
||||
@@ -34,9 +51,13 @@ export interface ComparisonLine {
|
||||
* Aggregated output of a dual-font layout pass.
|
||||
*/
|
||||
export interface ComparisonResult {
|
||||
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
|
||||
/**
|
||||
* Per-line grapheme data for both fonts. Empty when input text is empty.
|
||||
*/
|
||||
lines: ComparisonLine[];
|
||||
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||
/**
|
||||
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
|
||||
*/
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ describe('CharacterComparisonEngine', () => {
|
||||
engine = new CharacterComparisonEngine();
|
||||
});
|
||||
|
||||
// --- layout() ---
|
||||
|
||||
it('returns empty result for empty string', () => {
|
||||
const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
@@ -111,8 +109,6 @@ describe('CharacterComparisonEngine', () => {
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
|
||||
// --- getCharState() ---
|
||||
|
||||
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
|
||||
// 'A' only: FontA width=10. Container=500px. Line centered.
|
||||
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
|
||||
|
||||
@@ -10,16 +10,29 @@ import {
|
||||
* sequences and combining characters each produce exactly one entry.
|
||||
*/
|
||||
export interface LayoutLine {
|
||||
/** Full text of this line as returned by pretext. */
|
||||
/**
|
||||
* Full text of this line as returned by pretext.
|
||||
*/
|
||||
text: string;
|
||||
/** Rendered width of this line in pixels. */
|
||||
/**
|
||||
* Rendered width of this line in pixels.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Individual character metadata for this line
|
||||
*/
|
||||
chars: Array<{
|
||||
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||
/**
|
||||
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
|
||||
*/
|
||||
char: string;
|
||||
/** X offset from the start of the line, in pixels. */
|
||||
/**
|
||||
* X offset from the start of the line, in pixels.
|
||||
*/
|
||||
x: number;
|
||||
/** Advance width of this grapheme, in pixels. */
|
||||
/**
|
||||
* Advance width of this grapheme, in pixels.
|
||||
*/
|
||||
width: number;
|
||||
}>;
|
||||
}
|
||||
@@ -28,9 +41,13 @@ export interface LayoutLine {
|
||||
* Aggregated output of a single-font layout pass.
|
||||
*/
|
||||
export interface LayoutResult {
|
||||
/** Per-line grapheme data. Empty when input text is empty. */
|
||||
/**
|
||||
* Per-line grapheme data. Empty when input text is empty.
|
||||
*/
|
||||
lines: LayoutLine[];
|
||||
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||
/**
|
||||
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
|
||||
*/
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
@@ -65,7 +82,9 @@ export class TextLayoutEngine {
|
||||
*/
|
||||
#segmenter: Intl.Segmenter;
|
||||
|
||||
/** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */
|
||||
/**
|
||||
* @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale.
|
||||
*/
|
||||
constructor(locale?: string) {
|
||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
}, wait);
|
||||
|
||||
return {
|
||||
/** Current value with immediate updates (for UI binding) */
|
||||
/**
|
||||
* Current value with immediate updates (for UI binding)
|
||||
*/
|
||||
get immediate() {
|
||||
return immediate;
|
||||
},
|
||||
@@ -41,7 +43,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
// Manually trigger the debounce on write
|
||||
updateDebounced(value);
|
||||
},
|
||||
/** Current value with debounced updates (for logic/operations) */
|
||||
/**
|
||||
* Current value with debounced updates (for logic/operations)
|
||||
*/
|
||||
get debounced() {
|
||||
return debounced;
|
||||
},
|
||||
|
||||
@@ -28,7 +28,9 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
* Base entity interface requiring an ID field
|
||||
*/
|
||||
export interface Entity {
|
||||
/** Unique identifier for the entity */
|
||||
/**
|
||||
* Unique identifier for the entity
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -39,7 +41,9 @@ export interface Entity {
|
||||
* triggers updates when entities are added, removed, or modified.
|
||||
*/
|
||||
export class EntityStore<T extends Entity> {
|
||||
/** Reactive map of entities keyed by ID */
|
||||
/**
|
||||
* Reactive map of entities keyed by ID
|
||||
*/
|
||||
#entities = new SvelteMap<string, T>();
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,13 +29,21 @@
|
||||
* @template TValue - The type of the property value (typically string)
|
||||
*/
|
||||
export interface Property<TValue extends string> {
|
||||
/** Unique identifier for the property */
|
||||
/**
|
||||
* Unique string identifier for the filterable property
|
||||
*/
|
||||
id: string;
|
||||
/** Human-readable display name */
|
||||
/**
|
||||
* Human-readable label for UI display
|
||||
*/
|
||||
name: string;
|
||||
/** Underlying value for filtering logic */
|
||||
/**
|
||||
* Underlying machine-readable value used for filtering logic
|
||||
*/
|
||||
value: TValue;
|
||||
/** Whether the property is currently selected */
|
||||
/**
|
||||
* Current selection status (reactive)
|
||||
*/
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
@@ -45,7 +53,9 @@ export interface Property<TValue extends string> {
|
||||
* @template TValue - The type of property values
|
||||
*/
|
||||
export interface FilterModel<TValue extends string> {
|
||||
/** Array of filterable properties */
|
||||
/**
|
||||
* Collection of properties that can be toggled in this filter
|
||||
*/
|
||||
properties: Property<TValue>[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -32,19 +32,33 @@ import { Spring } from 'svelte/motion';
|
||||
* Configuration options for perspective effects
|
||||
*/
|
||||
export interface PerspectiveConfig {
|
||||
/** Z-axis translation per level in pixels */
|
||||
/**
|
||||
* Z-axis translation per level in pixels
|
||||
*/
|
||||
depthStep?: number;
|
||||
/** Scale reduction per level (0-1) */
|
||||
/**
|
||||
* Scale reduction per level (0-1)
|
||||
*/
|
||||
scaleStep?: number;
|
||||
/** Blur amount per level in pixels */
|
||||
/**
|
||||
* Blur amount per level in pixels
|
||||
*/
|
||||
blurStep?: number;
|
||||
/** Opacity reduction per level (0-1) */
|
||||
/**
|
||||
* Opacity reduction per level (0-1)
|
||||
*/
|
||||
opacityStep?: number;
|
||||
/** Parallax movement intensity per level */
|
||||
/**
|
||||
* Parallax movement intensity per level
|
||||
*/
|
||||
parallaxIntensity?: number;
|
||||
/** Horizontal offset - positive for right, negative for left */
|
||||
/**
|
||||
* Horizontal offset - positive for right, negative for left
|
||||
*/
|
||||
horizontalOffset?: number;
|
||||
/** Layout mode: 'center' for centered, 'split' for side-by-side */
|
||||
/**
|
||||
* Layout mode: 'center' for centered, 'split' for side-by-side
|
||||
*/
|
||||
layoutMode?: 'center' | 'split';
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,25 @@
|
||||
* Customize to match your design system's breakpoints.
|
||||
*/
|
||||
export interface Breakpoints {
|
||||
/** Mobile devices - default 640px */
|
||||
/**
|
||||
* Mobile devices - default 640px
|
||||
*/
|
||||
mobile: number;
|
||||
/** Tablet portrait - default 768px */
|
||||
/**
|
||||
* Tablet portrait - default 768px
|
||||
*/
|
||||
tabletPortrait: number;
|
||||
/** Tablet landscape - default 1024px */
|
||||
/**
|
||||
* Tablet landscape - default 1024px
|
||||
*/
|
||||
tablet: number;
|
||||
/** Desktop - default 1280px */
|
||||
/**
|
||||
* Desktop - default 1280px
|
||||
*/
|
||||
desktop: number;
|
||||
/** Large desktop - default 1536px */
|
||||
/**
|
||||
* Large desktop - default 1536px
|
||||
*/
|
||||
desktopLarge: number;
|
||||
}
|
||||
|
||||
@@ -206,66 +216,108 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
);
|
||||
|
||||
return {
|
||||
/** Viewport width in pixels */
|
||||
/**
|
||||
* Current viewport width in pixels (reactive)
|
||||
*/
|
||||
get width() {
|
||||
return width;
|
||||
},
|
||||
/** Viewport height in pixels */
|
||||
/**
|
||||
* Current viewport height in pixels (reactive)
|
||||
*/
|
||||
get height() {
|
||||
return height;
|
||||
},
|
||||
|
||||
// Standard breakpoints
|
||||
/**
|
||||
* True if viewport width is below the mobile threshold
|
||||
*/
|
||||
get isMobile() {
|
||||
return isMobile;
|
||||
},
|
||||
/**
|
||||
* True if viewport width is between mobile and tablet portrait thresholds
|
||||
*/
|
||||
get isTabletPortrait() {
|
||||
return isTabletPortrait;
|
||||
},
|
||||
/**
|
||||
* True if viewport width is between tablet portrait and desktop thresholds
|
||||
*/
|
||||
get isTablet() {
|
||||
return isTablet;
|
||||
},
|
||||
/**
|
||||
* True if viewport width is between desktop and large desktop thresholds
|
||||
*/
|
||||
get isDesktop() {
|
||||
return isDesktop;
|
||||
},
|
||||
/**
|
||||
* True if viewport width is at or above the large desktop threshold
|
||||
*/
|
||||
get isDesktopLarge() {
|
||||
return isDesktopLarge;
|
||||
},
|
||||
|
||||
// Convenience groupings
|
||||
/**
|
||||
* True if viewport width is below the desktop threshold
|
||||
*/
|
||||
get isMobileOrTablet() {
|
||||
return isMobileOrTablet;
|
||||
},
|
||||
/**
|
||||
* True if viewport width is at or above the tablet portrait threshold
|
||||
*/
|
||||
get isTabletOrDesktop() {
|
||||
return isTabletOrDesktop;
|
||||
},
|
||||
|
||||
// Orientation
|
||||
/**
|
||||
* Current screen orientation (portrait | landscape)
|
||||
*/
|
||||
get orientation() {
|
||||
return orientation;
|
||||
},
|
||||
/**
|
||||
* True if screen height is greater than width
|
||||
*/
|
||||
get isPortrait() {
|
||||
return isPortrait;
|
||||
},
|
||||
/**
|
||||
* True if screen width is greater than height
|
||||
*/
|
||||
get isLandscape() {
|
||||
return isLandscape;
|
||||
},
|
||||
|
||||
// Device capabilities
|
||||
/**
|
||||
* True if the device supports touch interaction
|
||||
*/
|
||||
get isTouchDevice() {
|
||||
return isTouchDevice;
|
||||
},
|
||||
|
||||
// Current breakpoint
|
||||
/**
|
||||
* Name of the currently active breakpoint (reactive)
|
||||
*/
|
||||
get currentBreakpoint() {
|
||||
return currentBreakpoint;
|
||||
},
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Initialization function to start event listeners
|
||||
*/
|
||||
init,
|
||||
/**
|
||||
* Helper to check for custom width ranges
|
||||
*/
|
||||
matches,
|
||||
|
||||
// Breakpoint values (for custom logic)
|
||||
/**
|
||||
* Underlying breakpoint pixel values
|
||||
*/
|
||||
breakpoints,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,13 +34,21 @@ import {
|
||||
* Defines the bounds and stepping behavior for a control
|
||||
*/
|
||||
export interface ControlDataModel {
|
||||
/** Current numeric value */
|
||||
/**
|
||||
* Initial or current numeric value
|
||||
*/
|
||||
value: number;
|
||||
/** Minimum allowed value (inclusive) */
|
||||
/**
|
||||
* Lower inclusive bound
|
||||
*/
|
||||
min: number;
|
||||
/** Maximum allowed value (inclusive) */
|
||||
/**
|
||||
* Upper inclusive bound
|
||||
*/
|
||||
max: number;
|
||||
/** Step size for increment/decrement operations */
|
||||
/**
|
||||
* Precision for increment/decrement operations
|
||||
*/
|
||||
step: number;
|
||||
}
|
||||
|
||||
@@ -50,13 +58,21 @@ export interface ControlDataModel {
|
||||
* @template T - Type for the control identifier
|
||||
*/
|
||||
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||
/** Unique identifier for the control */
|
||||
/**
|
||||
* Unique string identifier for the control
|
||||
*/
|
||||
id: T;
|
||||
/** ARIA label for the increase button */
|
||||
/**
|
||||
* Label used by screen readers for the increase button
|
||||
*/
|
||||
increaseLabel?: string;
|
||||
/** ARIA label for the decrease button */
|
||||
/**
|
||||
* Label used by screen readers for the decrease button
|
||||
*/
|
||||
decreaseLabel?: string;
|
||||
/** ARIA label for the control area */
|
||||
/**
|
||||
* Overall label describing the control's purpose
|
||||
*/
|
||||
controlLabel?: string;
|
||||
}
|
||||
|
||||
@@ -109,8 +125,7 @@ export function createTypographyControl<T extends ControlDataModel>(
|
||||
|
||||
return {
|
||||
/**
|
||||
* Current control value (getter/setter)
|
||||
* Setting automatically clamps to bounds and rounds to step precision
|
||||
* Clamped and rounded control value (reactive)
|
||||
*/
|
||||
get value() {
|
||||
return value;
|
||||
@@ -122,27 +137,37 @@ export function createTypographyControl<T extends ControlDataModel>(
|
||||
}
|
||||
},
|
||||
|
||||
/** Maximum allowed value */
|
||||
/**
|
||||
* Upper limit for the control value
|
||||
*/
|
||||
get max() {
|
||||
return max;
|
||||
},
|
||||
|
||||
/** Minimum allowed value */
|
||||
/**
|
||||
* Lower limit for the control value
|
||||
*/
|
||||
get min() {
|
||||
return min;
|
||||
},
|
||||
|
||||
/** Step increment size */
|
||||
/**
|
||||
* Configured step increment
|
||||
*/
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
|
||||
/** Whether the value is at or exceeds the maximum */
|
||||
/**
|
||||
* True if current value is equal to or greater than max
|
||||
*/
|
||||
get isAtMax() {
|
||||
return isAtMax;
|
||||
},
|
||||
|
||||
/** Whether the value is at or below the minimum */
|
||||
/**
|
||||
* True if current value is equal to or less than min
|
||||
*/
|
||||
get isAtMin() {
|
||||
return isAtMin;
|
||||
},
|
||||
|
||||
@@ -45,7 +45,9 @@ export interface VirtualItem {
|
||||
* Options are reactive - pass them through a function getter to enable updates.
|
||||
*/
|
||||
export interface VirtualizerOptions {
|
||||
/** Total number of items in the data array */
|
||||
/**
|
||||
* Total number of items in the underlying data array
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Function to estimate the size of an item at a given index.
|
||||
@@ -60,7 +62,10 @@ export interface VirtualizerOptions {
|
||||
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
||||
*/
|
||||
estimateSize: (index: number) => number;
|
||||
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
||||
/**
|
||||
* Number of extra items to render outside viewport for smoother scrolling
|
||||
* @default 5
|
||||
*/
|
||||
overscan?: number;
|
||||
/**
|
||||
* Function to get the key of an item at a given index.
|
||||
@@ -464,27 +469,45 @@ export function createVirtualizer<T>(
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Current vertical scroll position in pixels (reactive)
|
||||
*/
|
||||
get scrollOffset() {
|
||||
return scrollOffset;
|
||||
},
|
||||
/**
|
||||
* Measured height of the visible container area (reactive)
|
||||
*/
|
||||
get containerHeight() {
|
||||
return containerHeight;
|
||||
},
|
||||
/** Computed array of visible items to render (reactive) */
|
||||
/**
|
||||
* Computed array of visible items to render (reactive)
|
||||
*/
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
/** Total height of all items in pixels (reactive) */
|
||||
/**
|
||||
* Total height of all items in pixels (reactive)
|
||||
*/
|
||||
get totalSize() {
|
||||
return totalSize;
|
||||
},
|
||||
/** Svelte action for the scrollable container element */
|
||||
/**
|
||||
* Svelte action for the scrollable container element
|
||||
*/
|
||||
container,
|
||||
/** Svelte action for measuring individual item elements */
|
||||
/**
|
||||
* Svelte action for measuring individual item elements
|
||||
*/
|
||||
measureElement,
|
||||
/** Programmatic scroll method to scroll to a specific item */
|
||||
/**
|
||||
* Programmatic scroll method to scroll to a specific item
|
||||
*/
|
||||
scrollToIndex,
|
||||
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||
/**
|
||||
* Programmatic scroll method to scroll to a specific pixel offset
|
||||
*/
|
||||
scrollToOffset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
|
||||
@@ -22,59 +22,178 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Filter management
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Reactive filter factory
|
||||
*/
|
||||
createFilter,
|
||||
/**
|
||||
* Filter instance type
|
||||
*/
|
||||
type Filter,
|
||||
/**
|
||||
* Initial state model
|
||||
*/
|
||||
type FilterModel,
|
||||
/**
|
||||
* Filterable property definition
|
||||
*/
|
||||
type Property,
|
||||
} from './createFilter/createFilter.svelte';
|
||||
|
||||
/**
|
||||
* Bounded numeric controls
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Base numeric configuration
|
||||
*/
|
||||
type ControlDataModel,
|
||||
/**
|
||||
* Extended model with labels
|
||||
*/
|
||||
type ControlModel,
|
||||
/**
|
||||
* Reactive control factory
|
||||
*/
|
||||
createTypographyControl,
|
||||
/**
|
||||
* Control instance type
|
||||
*/
|
||||
type TypographyControl,
|
||||
} from './createTypographyControl/createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* List virtualization
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Reactive virtualizer factory
|
||||
*/
|
||||
createVirtualizer,
|
||||
/**
|
||||
* Rendered item layout data
|
||||
*/
|
||||
type VirtualItem,
|
||||
/**
|
||||
* Virtualizer instance type
|
||||
*/
|
||||
type Virtualizer,
|
||||
/**
|
||||
* Configuration options
|
||||
*/
|
||||
type VirtualizerOptions,
|
||||
} from './createVirtualizer/createVirtualizer.svelte';
|
||||
|
||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||
|
||||
/**
|
||||
* UI State
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Immediate/debounced state factory
|
||||
*/
|
||||
createDebouncedState,
|
||||
} from './createDebouncedState/createDebouncedState.svelte';
|
||||
|
||||
/**
|
||||
* Entity collections
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Reactive entity store factory
|
||||
*/
|
||||
createEntityStore,
|
||||
/**
|
||||
* Base entity requirement
|
||||
*/
|
||||
type Entity,
|
||||
/**
|
||||
* Entity store instance type
|
||||
*/
|
||||
type EntityStore,
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
/**
|
||||
* Comparison logic
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Character-by-character comparison utility
|
||||
*/
|
||||
CharacterComparisonEngine,
|
||||
/**
|
||||
* Single line of comparison results
|
||||
*/
|
||||
type ComparisonLine,
|
||||
/**
|
||||
* Full comparison output
|
||||
*/
|
||||
type ComparisonResult,
|
||||
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||
|
||||
/**
|
||||
* Text layout
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Single line layout information
|
||||
*/
|
||||
type LayoutLine as TextLayoutLine,
|
||||
/**
|
||||
* Full multi-line layout information
|
||||
*/
|
||||
type LayoutResult as TextLayoutResult,
|
||||
/**
|
||||
* High-level text measurement engine
|
||||
*/
|
||||
TextLayoutEngine,
|
||||
} from './TextLayoutEngine/TextLayoutEngine.svelte';
|
||||
|
||||
/**
|
||||
* Persistence
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* LocalStorage-backed reactive store factory
|
||||
*/
|
||||
createPersistentStore,
|
||||
/**
|
||||
* Persistent store instance type
|
||||
*/
|
||||
type PersistentStore,
|
||||
} from './createPersistentStore/createPersistentStore.svelte';
|
||||
|
||||
/**
|
||||
* Responsive design
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Breakpoint tracking factory
|
||||
*/
|
||||
createResponsiveManager,
|
||||
/**
|
||||
* Responsive manager instance type
|
||||
*/
|
||||
type ResponsiveManager,
|
||||
/**
|
||||
* Singleton manager for global usage
|
||||
*/
|
||||
responsiveManager,
|
||||
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||
|
||||
/**
|
||||
* 3D Perspectives
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Motion-aware perspective factory
|
||||
*/
|
||||
createPerspectiveManager,
|
||||
/**
|
||||
* Perspective manager instance type
|
||||
*/
|
||||
type PerspectiveManager,
|
||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
* @template T - Type of the response data
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
/** Response payload data */
|
||||
/**
|
||||
* Primary data payload returned by the server
|
||||
*/
|
||||
data: T;
|
||||
/** HTTP status code */
|
||||
/**
|
||||
* HTTP status code (e.g. 200, 404, 500)
|
||||
*/
|
||||
status: number;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ interface Props extends HTMLAttributes<HTMLSpanElement> {
|
||||
* @default false
|
||||
*/
|
||||
dot?: boolean;
|
||||
/**
|
||||
* Prevent text wrapping
|
||||
* @default false
|
||||
*/
|
||||
nowrap?: boolean;
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
@@ -51,6 +56,7 @@ let {
|
||||
variant = 'default',
|
||||
size = 'xs',
|
||||
dot = false,
|
||||
nowrap = false,
|
||||
children,
|
||||
class: className,
|
||||
...rest
|
||||
@@ -63,6 +69,7 @@ let {
|
||||
'font-mono uppercase tracking-wide',
|
||||
labelSizeConfig[size],
|
||||
badgeVariantConfig[variant],
|
||||
nowrap && 'text-nowrap',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -111,7 +111,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
),
|
||||
ghost: cn(
|
||||
'bg-transparent',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
'hover:bg-transparent dark:hover:bg-transparent',
|
||||
'hover:text-brand dark:hover:text-brand',
|
||||
@@ -121,7 +121,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
),
|
||||
icon: cn(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
'hover:bg-paper dark:hover:bg-paper',
|
||||
'hover:text-brand',
|
||||
@@ -150,11 +150,11 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'h-6 px-2 text-[9px] gap-1',
|
||||
sm: 'h-8 px-3 text-[10px] gap-1.5',
|
||||
md: 'h-10 px-4 text-[11px] gap-2',
|
||||
lg: 'h-12 px-6 text-[12px] gap-2',
|
||||
xl: 'h-14 px-8 text-[13px] gap-2.5',
|
||||
xs: 'h-6 px-2 text-3xs gap-1',
|
||||
sm: 'h-8 px-3 text-2xs gap-1.5',
|
||||
md: 'h-10 px-4 text-xs gap-2',
|
||||
lg: 'h-12 px-6 text-xs gap-2',
|
||||
xl: 'h-14 px-8 text-sm gap-2.5',
|
||||
};
|
||||
|
||||
// Square padding for icon-only mode
|
||||
@@ -172,7 +172,7 @@ const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||
};
|
||||
|
||||
const classes = $derived(cn(
|
||||
@@ -184,7 +184,7 @@ const classes = $derived(cn(
|
||||
'select-none',
|
||||
'outline-none',
|
||||
'cursor-pointer',
|
||||
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
|
||||
'focus-ring',
|
||||
'focus-visible:ring-offset-surface dark:focus-visible:ring-offset-dark-bg',
|
||||
'disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
// Variant
|
||||
|
||||
@@ -26,7 +26,7 @@ let { children, class: className, ...rest }: Props = $props();
|
||||
class={cn(
|
||||
'flex items-center gap-1 p-1',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'border border-subtle',
|
||||
'rounded-none',
|
||||
'transition-colors duration-500',
|
||||
className,
|
||||
|
||||
@@ -93,9 +93,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
step={control.step}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<span
|
||||
class="font-mono text-[0.6875rem] text-neutral-500 dark:text-neutral-400 tabular-nums w-10 text-right shrink-0"
|
||||
>
|
||||
<span class="font-mono text-xs text-secondary tabular-nums w-10 text-right shrink-0">
|
||||
{formattedValue()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,7 +127,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
'border border-transparent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
open
|
||||
? 'bg-paper dark:bg-dark-card shadow-sm border-black/5 dark:border-white/10'
|
||||
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||
)}
|
||||
aria-label={controlLabel}
|
||||
@@ -138,7 +136,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
{#if displayLabel}
|
||||
<span
|
||||
class="
|
||||
text-[0.5625rem] font-primary font-bold tracking-tight uppercase
|
||||
text-3xs font-primary font-bold tracking-tight uppercase
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
mb-0.5 leading-none
|
||||
"
|
||||
@@ -157,7 +155,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Vertical slider popover -->
|
||||
<PopoverContent
|
||||
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-black/5 dark:border-white/10 shadow-sm bg-paper dark:bg-dark-card"
|
||||
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"
|
||||
align="center"
|
||||
side="top"
|
||||
>
|
||||
|
||||
@@ -24,8 +24,8 @@ interface Props {
|
||||
const { label, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-black/5 dark:border-white/10 last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-[0.6875rem] font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
{label}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -27,14 +27,14 @@ const { children, class: className, render }: Props = $props();
|
||||
{#if render}
|
||||
{@render render({
|
||||
class: cn(
|
||||
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
{:else if children}
|
||||
<span
|
||||
class={cn(
|
||||
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,10 @@ import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import { scale } from 'svelte/transition';
|
||||
import {
|
||||
inputSizeConfig,
|
||||
inputVariantConfig,
|
||||
} from './config';
|
||||
import type {
|
||||
InputSize,
|
||||
InputVariant,
|
||||
@@ -80,36 +84,11 @@ let {
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
|
||||
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
|
||||
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
|
||||
};
|
||||
|
||||
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
|
||||
default: {
|
||||
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
underline: {
|
||||
base: 'bg-transparent border-0 border-b border-neutral-300 dark:border-neutral-700',
|
||||
focus: 'focus:border-brand',
|
||||
error: 'border-brand',
|
||||
},
|
||||
filled: {
|
||||
base: 'bg-surface dark:bg-paper border border-transparent',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
};
|
||||
|
||||
const hasValue = $derived(value !== undefined && value !== '');
|
||||
const showClear = $derived(showClearButton && hasValue && !!onclear);
|
||||
const hasRightSlot = $derived(!!rightIcon || showClearButton);
|
||||
const cfg = $derived(sizeConfig[size]);
|
||||
const styles = $derived(variantConfig[variant]);
|
||||
const cfg = $derived(inputSizeConfig[size]);
|
||||
const styles = $derived(inputVariantConfig[variant]);
|
||||
|
||||
const inputClasses = $derived(cn(
|
||||
'font-primary rounded-none outline-none transition-all duration-200',
|
||||
@@ -169,8 +148,8 @@ const inputClasses = $derived(cn(
|
||||
{#if helperText}
|
||||
<span
|
||||
class={cn(
|
||||
'text-[0.625rem] font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-neutral-500 dark:text-neutral-400',
|
||||
'text-2xs font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-secondary',
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
|
||||
35
src/shared/ui/Input/config.ts
Normal file
35
src/shared/ui/Input/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
InputSize,
|
||||
InputVariant,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Size-specific layout classes: padding, text size, height, and clear-icon pixel size.
|
||||
*/
|
||||
export const inputSizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
|
||||
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
|
||||
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant-specific classes: base background/border, focus ring, and error state.
|
||||
*/
|
||||
export const inputVariantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
|
||||
default: {
|
||||
base: 'bg-paper dark:bg-paper border border-subtle',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
underline: {
|
||||
base: 'bg-transparent border-0 border-b border-neutral-300 dark:border-neutral-700',
|
||||
focus: 'focus:border-brand',
|
||||
error: 'border-brand',
|
||||
},
|
||||
filled: {
|
||||
base: 'bg-surface dark:bg-paper border border-transparent',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
export type InputVariant = 'default' | 'underline' | 'filled';
|
||||
export type InputSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Convenience map for consumers sizing icons to match the input. */
|
||||
/**
|
||||
* Convenience map for consumers sizing icons to match the input.
|
||||
*/
|
||||
export const inputIconSize: Record<InputSize, number> = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelFont,
|
||||
type LabelSize,
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
@@ -28,6 +29,11 @@ interface Props {
|
||||
* @default true
|
||||
*/
|
||||
uppercase?: boolean;
|
||||
/**
|
||||
* Font family
|
||||
* @default 'mono'
|
||||
*/
|
||||
font?: LabelFont;
|
||||
/**
|
||||
* Bold text
|
||||
* @default false
|
||||
@@ -55,6 +61,7 @@ interface Props {
|
||||
let {
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
font = 'mono',
|
||||
uppercase = true,
|
||||
bold = false,
|
||||
icon,
|
||||
@@ -68,6 +75,7 @@ let {
|
||||
class={cn(
|
||||
'font-mono tracking-widest leading-none',
|
||||
'inline-flex items-center gap-1.5',
|
||||
font === 'primary' && 'font-primary tracking-tight',
|
||||
labelSizeConfig[size],
|
||||
labelVariantConfig[variant],
|
||||
uppercase && 'uppercase',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Import from here in each component to keep maps DRY.
|
||||
*/
|
||||
|
||||
export type LabelFont = 'mono' | 'primary';
|
||||
|
||||
export type LabelVariant =
|
||||
| 'default'
|
||||
| 'accent'
|
||||
@@ -14,10 +16,10 @@ export type LabelVariant =
|
||||
export type LabelSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export const labelSizeConfig: Record<LabelSize, string> = {
|
||||
xs: 'text-[0.5rem]',
|
||||
sm: 'text-[0.5625rem] md:text-[0.625rem]',
|
||||
md: 'text-[0.625rem] md:text-[0.6875rem]',
|
||||
lg: 'text-[0.8rem] md:text-[0.875rem]',
|
||||
xs: 'text-4xs',
|
||||
sm: 'text-3xs md:text-2xs',
|
||||
md: 'text-2xs md:text-xs',
|
||||
lg: 'text-sm',
|
||||
};
|
||||
|
||||
export const labelVariantConfig: Record<LabelVariant, string> = {
|
||||
|
||||
@@ -71,7 +71,7 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
|
||||
<div class="w-px h-3 bg-text-muted/50"></div>
|
||||
|
||||
<!-- Message -->
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-text-subtle font-medium">
|
||||
<span class="font-mono text-2xs uppercase tracking-wider-mono text-text-subtle font-medium">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ let {
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Input bind:value variant="underline" {...rest}>
|
||||
<Input bind:value variant="default" {...rest}>
|
||||
{#snippet rightIcon(size)}
|
||||
<SearchIcon size={inputIconSize[size]} />
|
||||
{/snippet}
|
||||
|
||||
@@ -53,7 +53,7 @@ const indexStr = $derived(String(index).padStart(2, '0'));
|
||||
</div>
|
||||
|
||||
{#if subtitle}
|
||||
<span class="text-neutral-300 dark:text-neutral-700 text-[0.625rem] hidden md:inline">/</span>
|
||||
<span class="text-neutral-300 dark:text-neutral-700 text-2xs hidden md:inline">/</span>
|
||||
<Label variant="muted" size="sm">{subtitle}</Label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
const { text }: Props = $props();
|
||||
</script>
|
||||
{#if text}
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-['Space_Grotesk'] font-bold text-swiss-black dark:text-neutral-200 tracking-tight">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-primary font-bold text-swiss-black dark:text-neutral-200 tracking-tight">
|
||||
{text}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
@@ -84,7 +84,7 @@ function close() {
|
||||
'overflow-hidden',
|
||||
'will-change-[width]',
|
||||
'transition-[width] duration-300 ease-out',
|
||||
'border-r border-black/5 dark:border-white/10',
|
||||
'border-r border-subtle',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
isOpen ? 'w-80 opacity-100' : 'w-0 opacity-0',
|
||||
'transition-[width,opacity] duration-300 ease-out',
|
||||
|
||||
@@ -69,8 +69,8 @@ let {
|
||||
|
||||
const isVertical = $derived(orientation === 'vertical');
|
||||
|
||||
const labelClasses = `font-mono text-[0.625rem] tabular-nums shrink-0
|
||||
text-neutral-500 dark:text-neutral-400
|
||||
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
||||
text-secondary
|
||||
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
||||
transition-colors`;
|
||||
|
||||
|
||||
@@ -1,28 +1,150 @@
|
||||
export { default as Badge } from './Badge/Badge.svelte';
|
||||
export {
|
||||
/**
|
||||
* Pill-shaped status indicator
|
||||
*/
|
||||
default as Badge,
|
||||
} from './Badge/Badge.svelte';
|
||||
export {
|
||||
/**
|
||||
* Main action trigger
|
||||
*/
|
||||
Button,
|
||||
/**
|
||||
* Horizontal layout for related buttons
|
||||
*/
|
||||
ButtonGroup,
|
||||
/**
|
||||
* Button optimized for single-icon display
|
||||
*/
|
||||
IconButton,
|
||||
/**
|
||||
* State-aware toggle switch
|
||||
*/
|
||||
ToggleButton,
|
||||
} from './Button';
|
||||
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
||||
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
|
||||
export { default as ControlGroup } from './ControlGroup/ControlGroup.svelte';
|
||||
export { default as Divider } from './Divider/Divider.svelte';
|
||||
export { default as FilterGroup } from './FilterGroup/FilterGroup.svelte';
|
||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||
export { default as Input } from './Input/Input.svelte';
|
||||
export { default as Label } from './Label/Label.svelte';
|
||||
export { default as Loader } from './Loader/Loader.svelte';
|
||||
export { default as Logo } from './Logo/Logo.svelte';
|
||||
export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
|
||||
export { default as Section } from './Section/Section.svelte';
|
||||
export type { TitleStatusChangeHandler } from './Section/types';
|
||||
export { default as SidebarContainer } from './SidebarContainer/SidebarContainer.svelte';
|
||||
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
|
||||
export { default as Slider } from './Slider/Slider.svelte';
|
||||
export { default as Stat } from './Stat/Stat.svelte';
|
||||
export { default as StatGroup } from './Stat/StatGroup.svelte';
|
||||
export { default as TechText } from './TechText/TechText.svelte';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||
export {
|
||||
/**
|
||||
* Input with associated increment/decrement controls
|
||||
*/
|
||||
default as ComboControl,
|
||||
} from './ComboControl/ComboControl.svelte';
|
||||
export {
|
||||
/**
|
||||
* Rich text input using contenteditable attribute
|
||||
*/
|
||||
default as ContentEditable,
|
||||
} from './ContentEditable/ContentEditable.svelte';
|
||||
export {
|
||||
/**
|
||||
* Semantic grouping for related UI controls
|
||||
*/
|
||||
default as ControlGroup,
|
||||
} from './ControlGroup/ControlGroup.svelte';
|
||||
export {
|
||||
/**
|
||||
* Simple horizontal line separator
|
||||
*/
|
||||
default as Divider,
|
||||
} from './Divider/Divider.svelte';
|
||||
export {
|
||||
/**
|
||||
* Filterable property set with selection logic
|
||||
*/
|
||||
default as FilterGroup,
|
||||
} from './FilterGroup/FilterGroup.svelte';
|
||||
export {
|
||||
/**
|
||||
* Small text for secondary meta-information
|
||||
*/
|
||||
default as Footnote,
|
||||
} from './Footnote/Footnote.svelte';
|
||||
export {
|
||||
/**
|
||||
* Design-system standard text input
|
||||
*/
|
||||
default as Input,
|
||||
} from './Input/Input.svelte';
|
||||
export {
|
||||
/**
|
||||
* Text label for input fields
|
||||
*/
|
||||
default as Label,
|
||||
} from './Label/Label.svelte';
|
||||
export {
|
||||
/**
|
||||
* Full-page or component-level progress spinner
|
||||
*/
|
||||
default as Loader,
|
||||
} from './Loader/Loader.svelte';
|
||||
export {
|
||||
/**
|
||||
* Main application logo
|
||||
*/
|
||||
default as Logo,
|
||||
} from './Logo/Logo.svelte';
|
||||
export {
|
||||
/**
|
||||
* 3D perspective background/container
|
||||
*/
|
||||
default as PerspectivePlan,
|
||||
} from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export {
|
||||
/**
|
||||
* Specialized input with search icon and clear state
|
||||
*/
|
||||
default as SearchBar,
|
||||
} from './SearchBar/SearchBar.svelte';
|
||||
export {
|
||||
/**
|
||||
* Content section with header and optional title tracking
|
||||
*/
|
||||
default as Section,
|
||||
} from './Section/Section.svelte';
|
||||
export {
|
||||
/**
|
||||
* Callback for section visibility status changes
|
||||
*/
|
||||
type TitleStatusChangeHandler,
|
||||
} from './Section/types';
|
||||
export {
|
||||
/**
|
||||
* Structural sidebar component
|
||||
*/
|
||||
default as SidebarContainer,
|
||||
} from './SidebarContainer/SidebarContainer.svelte';
|
||||
export {
|
||||
/**
|
||||
* Loading placeholder with pulsing animation
|
||||
*/
|
||||
default as Skeleton,
|
||||
} from './Skeleton/Skeleton.svelte';
|
||||
export {
|
||||
/**
|
||||
* Range selector with numeric feedback
|
||||
*/
|
||||
default as Slider,
|
||||
} from './Slider/Slider.svelte';
|
||||
export {
|
||||
/**
|
||||
* Individual numeric statistic display
|
||||
*/
|
||||
default as Stat,
|
||||
} from './Stat/Stat.svelte';
|
||||
export {
|
||||
/**
|
||||
* Grouping for multiple statistics
|
||||
*/
|
||||
default as StatGroup,
|
||||
} from './Stat/StatGroup.svelte';
|
||||
export {
|
||||
/**
|
||||
* Mono-spaced technical/metadata text
|
||||
*/
|
||||
default as TechText,
|
||||
} from './TechText/TechText.svelte';
|
||||
export {
|
||||
/**
|
||||
* High-performance list renderer for large datasets
|
||||
*/
|
||||
default as VirtualList,
|
||||
} from './VirtualList/VirtualList.svelte';
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
fontStore,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
createTypographyControlManager,
|
||||
} from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -32,9 +29,13 @@ import { untrack } from 'svelte';
|
||||
* Storage schema for comparison state
|
||||
*/
|
||||
interface ComparisonState {
|
||||
/** Font ID for side A (left/top) */
|
||||
/**
|
||||
* Unique identifier for the primary font being compared (Side A)
|
||||
*/
|
||||
fontAId: string | null;
|
||||
/** Font ID for side B (right/bottom) */
|
||||
/**
|
||||
* Unique identifier for the secondary font being compared (Side B)
|
||||
*/
|
||||
fontBId: string | null;
|
||||
}
|
||||
|
||||
@@ -56,21 +57,33 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
* storage is empty.
|
||||
*/
|
||||
export class ComparisonStore {
|
||||
/** Font for side A */
|
||||
/**
|
||||
* The primary font model for Side A (left/top)
|
||||
*/
|
||||
#fontA = $state<UnifiedFont | undefined>();
|
||||
/** Font for side B */
|
||||
/**
|
||||
* The secondary font model for Side B (right/bottom)
|
||||
*/
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
/** Sample text to display */
|
||||
/**
|
||||
* The preview text string displayed in the comparison area
|
||||
*/
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
/** Whether fonts are loaded and ready to display */
|
||||
/**
|
||||
* Flag indicating if both fonts are successfully loaded and ready for rendering
|
||||
*/
|
||||
#fontsReady = $state(false);
|
||||
/** Active side for single-font operations */
|
||||
/**
|
||||
* Currently active side (A or B) for single-font adjustments
|
||||
*/
|
||||
#side = $state<Side>('A');
|
||||
/** Slider position for character morphing (0-100) */
|
||||
/**
|
||||
* Interactive slider position (0-100) used for morphing/layout transitions
|
||||
*/
|
||||
#sliderPosition = $state(50);
|
||||
/** Typography controls for this comparison */
|
||||
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||
/** TanStack Query-backed batch font fetcher */
|
||||
/**
|
||||
* TanStack Query-backed store for efficient batch font retrieval
|
||||
*/
|
||||
#batchStore: BatchFontStore;
|
||||
|
||||
constructor() {
|
||||
@@ -99,7 +112,7 @@ export class ComparisonStore {
|
||||
$effect(() => {
|
||||
const fa = this.#fontA;
|
||||
const fb = this.#fontB;
|
||||
const weight = this.#typography.weight;
|
||||
const weight = typographySettingsStore.weight;
|
||||
|
||||
if (!fa || !fb) return;
|
||||
|
||||
@@ -137,6 +150,19 @@ export class ComparisonStore {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
|
||||
$effect(() => {
|
||||
const fa = this.#fontA;
|
||||
const fb = this.#fontB;
|
||||
const w = typographySettingsStore.weight;
|
||||
if (fa) appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
|
||||
if (fb) appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
|
||||
return () => {
|
||||
if (fa) appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
|
||||
if (fb) appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,8 +178,8 @@ export class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = this.#typography.weight;
|
||||
const size = this.#typography.renderedSize;
|
||||
const weight = typographySettingsStore.weight;
|
||||
const size = typographySettingsStore.renderedSize;
|
||||
const fontAName = this.#fontA?.name;
|
||||
const fontBName = this.#fontB?.name;
|
||||
|
||||
@@ -201,14 +227,9 @@ export class ComparisonStore {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Getters / Setters ─────────────────────────────────────────────────────
|
||||
|
||||
/** Typography control manager */
|
||||
get typography() {
|
||||
return this.#typography;
|
||||
}
|
||||
|
||||
/** Font for side A */
|
||||
/**
|
||||
* Primary font for comparison (reactive)
|
||||
*/
|
||||
get fontA() {
|
||||
return this.#fontA;
|
||||
}
|
||||
@@ -218,7 +239,9 @@ export class ComparisonStore {
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
/** Font for side B */
|
||||
/**
|
||||
* Secondary font for comparison (reactive)
|
||||
*/
|
||||
get fontB() {
|
||||
return this.#fontB;
|
||||
}
|
||||
@@ -228,7 +251,9 @@ export class ComparisonStore {
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
/** Sample text to display */
|
||||
/**
|
||||
* Shared preview text string (reactive)
|
||||
*/
|
||||
get text() {
|
||||
return this.#sampleText;
|
||||
}
|
||||
@@ -237,7 +262,9 @@ export class ComparisonStore {
|
||||
this.#sampleText = value;
|
||||
}
|
||||
|
||||
/** Active side for single-font operations */
|
||||
/**
|
||||
* Side currently selected for focused manipulation (reactive)
|
||||
*/
|
||||
get side() {
|
||||
return this.#side;
|
||||
}
|
||||
@@ -246,7 +273,9 @@ export class ComparisonStore {
|
||||
this.#side = value;
|
||||
}
|
||||
|
||||
/** Slider position (0-100) for character morphing */
|
||||
/**
|
||||
* Morphing slider position (0-100) used by Character components (reactive)
|
||||
*/
|
||||
get sliderPosition() {
|
||||
return this.#sliderPosition;
|
||||
}
|
||||
@@ -255,12 +284,16 @@ export class ComparisonStore {
|
||||
this.#sliderPosition = value;
|
||||
}
|
||||
|
||||
/** Whether both fonts are selected and loaded */
|
||||
/**
|
||||
* True if both fonts are ready for side-by-side display (reactive)
|
||||
*/
|
||||
get isReady() {
|
||||
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
|
||||
}
|
||||
|
||||
/** Whether currently loading (batch fetch in flight or fonts not yet painted) */
|
||||
/**
|
||||
* True if any font is currently being fetched or loaded (reactive)
|
||||
*/
|
||||
get isLoading() {
|
||||
return this.#batchStore.isLoading || !this.#fontsReady;
|
||||
}
|
||||
@@ -273,7 +306,7 @@ export class ComparisonStore {
|
||||
this.#fontB = undefined;
|
||||
this.#batchStore.setIds([]);
|
||||
storage.clear();
|
||||
this.#typography.reset();
|
||||
typographySettingsStore.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||
@@ -18,8 +20,6 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
// ── Persistent-store mock ─────────────────────────────────────────────────────
|
||||
|
||||
const mockStorage = vi.hoisted(() => {
|
||||
const storage: any = {};
|
||||
storage._value = { fontAId: null, fontBId: null };
|
||||
@@ -51,17 +51,19 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
||||
createPersistentStore: vi.fn(() => mockStorage),
|
||||
}));
|
||||
|
||||
// ── $entities/Font mock — keep real BatchFontStore, stub singletons ───────────
|
||||
|
||||
vi.mock('$entities/Font', async () => {
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
const { BatchFontStore } = await import(
|
||||
'$entities/Font/model/store/batchFontStore.svelte'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
BatchFontStore,
|
||||
fontStore: { fonts: [] },
|
||||
appliedFontsManager: {
|
||||
touch: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
getFontStatus: vi.fn(),
|
||||
ready: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
@@ -69,8 +71,6 @@ vi.mock('$entities/Font', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// ── $features/SetupFont mock ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock('$features/SetupFont', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
@@ -80,14 +80,21 @@ vi.mock('$features/SetupFont', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
vi.mock('$features/SetupFont/model', () => ({
|
||||
typographySettingsStore: {
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { fontStore } from '$entities/Font';
|
||||
import {
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
} from '$entities/Font';
|
||||
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
||||
import { ComparisonStore } from './comparisonStore.svelte';
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComparisonStore', () => {
|
||||
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
|
||||
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
|
||||
@@ -114,8 +121,6 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create store with initial empty state', () => {
|
||||
const store = new ComparisonStore();
|
||||
@@ -124,8 +129,6 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Restoration from Storage ──────────────────────────────────────────────
|
||||
|
||||
describe('Restoration from Storage (via BatchFontStore)', () => {
|
||||
it('should restore fontA and fontB from stored IDs', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
@@ -154,8 +157,6 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default Fallbacks ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Default Fallbacks', () => {
|
||||
it('should update storage with default IDs when storage is empty', async () => {
|
||||
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||
@@ -170,8 +171,6 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading State ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Aggregate Loading State', () => {
|
||||
it('should be loading initially when storage has IDs', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
@@ -187,8 +186,6 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reset Functionality', () => {
|
||||
it('should reset all state and clear storage', () => {
|
||||
const store = new ComparisonStore();
|
||||
@@ -209,4 +206,53 @@ describe('ComparisonStore', () => {
|
||||
expect(store.fontB).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pin / Unpin (eviction guard)', () => {
|
||||
it('pins fontA and fontB when they are loaded', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
new ComparisonStore();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontB.id,
|
||||
400,
|
||||
mockFontB.features?.isVariable,
|
||||
);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('unpins the old font when fontA is replaced', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
|
||||
|
||||
const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' };
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]);
|
||||
store.fontA = mockFontC;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.unpin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontC.id,
|
||||
400,
|
||||
mockFontC.features?.isVariable,
|
||||
);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Renders a single character with morphing animation
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
@@ -25,7 +26,7 @@ let { char, proximity, isPast }: Props = $props();
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let slot = $state<0 | 1>(0);
|
||||
let slotFonts = $state<[string, string]>(['', '']);
|
||||
@@ -51,6 +52,7 @@ $effect(() => {
|
||||
<span
|
||||
class={cn(
|
||||
'char-inner',
|
||||
'transition-colors duration-300',
|
||||
isPast
|
||||
? 'text-swiss-black/75 dark:text-brand/75'
|
||||
: 'text-neutral-950 dark:text-white',
|
||||
|
||||
@@ -6,23 +6,18 @@
|
||||
<script lang="ts">
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
ControlGroup,
|
||||
SidebarContainer,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import { SidebarContainer } from '$shared/ui';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
import FontList from '../FontList/FontList.svelte';
|
||||
import Header from '../Header/Header.svelte';
|
||||
import Search from '../Search/Search.svelte';
|
||||
import Sidebar from '../Sidebar/Sidebar.svelte';
|
||||
import SliderArea from '../SliderArea/SliderArea.svelte';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||
let isSidebarOpen = $state(!isMobileOrTabletPortrait);
|
||||
|
||||
@@ -43,52 +38,9 @@ $effect(() => {
|
||||
{#snippet sidebar()}
|
||||
<Sidebar class="w-full h-full border-none">
|
||||
{#snippet main()}
|
||||
<Search />
|
||||
<FontList />
|
||||
{/snippet}
|
||||
|
||||
{#snippet controls()}
|
||||
{#if typography.sizeControl && typography.weightControl && typography.heightControl && typography.spacingControl}
|
||||
<ControlGroup label="Size">
|
||||
<Slider
|
||||
bind:value={typography.sizeControl.value}
|
||||
min={typography.sizeControl.min}
|
||||
max={typography.sizeControl.max}
|
||||
step={typography.sizeControl.step}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Weight">
|
||||
<Slider
|
||||
bind:value={typography.weightControl.value}
|
||||
min={typography.weightControl.min}
|
||||
max={typography.weightControl.max}
|
||||
step={typography.weightControl.step}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mt-4">
|
||||
<ControlGroup label="Leading" class="border-0 py-0">
|
||||
<Slider
|
||||
bind:value={typography.heightControl.value}
|
||||
min={typography.heightControl.min}
|
||||
max={typography.heightControl.max}
|
||||
step={typography.heightControl.step}
|
||||
format={(v => v.toFixed(1))}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Tracking" class="border-0 py-0">
|
||||
<Slider
|
||||
bind:value={typography.spacingControl.value}
|
||||
min={typography.spacingControl.min}
|
||||
max={typography.spacingControl.max}
|
||||
step={typography.spacingControl.step}
|
||||
format={(v => v.toFixed(2))}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Sidebar>
|
||||
{/snippet}
|
||||
</SidebarContainer>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
FontApplicator,
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
@@ -18,8 +19,6 @@ import { crossfade } from 'svelte/transition';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
const side = $derived(comparisonStore.side);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
let prevIndexA: number | null = null;
|
||||
let prevIndexB: number | null = null;
|
||||
let selectedIndexA: number | null = null;
|
||||
@@ -71,17 +70,17 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 h-full">
|
||||
<div class="py-2 pl-4 relative flex flex-col min-h-0 h-full">
|
||||
<div class="px-2 py-4 mr-4 sticky border-b border-black/5 dark:border-white/10 mb-2">
|
||||
<Label class="font-primary text-neutral-400" bold variant="default" size="sm" uppercase>
|
||||
<div class="py-2 relative flex flex-col min-h-0 h-full">
|
||||
<div class="py-2 mx-6 sticky border-b border-subtle">
|
||||
<Label font="primary" variant="muted" bold size="sm" uppercase>
|
||||
Typeface Selection
|
||||
</Label>
|
||||
</div>
|
||||
<FontVirtualList
|
||||
data-font-list
|
||||
weight={typography.weight}
|
||||
weight={DEFAULT_FONT_WEIGHT}
|
||||
itemHeight={45}
|
||||
class="bg-transparent min-h-0 h-full scroll-stable pr-4"
|
||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||
>
|
||||
{#snippet children({ item: font, index })}
|
||||
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
|
||||
@@ -95,7 +94,7 @@ $effect(() => {
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="right"
|
||||
>
|
||||
<FontApplicator {font} weight={typography.weight}>{font.name}</FontApplicator>
|
||||
<FontApplicator {font}>{font.name}</FontApplicator>
|
||||
|
||||
{#snippet icon()}
|
||||
{#if active}
|
||||
|
||||
@@ -53,7 +53,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
||||
'flex items-center justify-between',
|
||||
'px-4 md:px-8 py-4 md:py-6',
|
||||
'h-16 md:h-20 z-20',
|
||||
'border-b border-black/5 dark:border-white/10',
|
||||
'border-b border-subtle',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Renders a line of text in the SliderArea
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
interface LineChar {
|
||||
char: string;
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
*/
|
||||
character: Snippet<[{ char: string; index: number }]>;
|
||||
}
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let { chars, character }: Props = $props();
|
||||
</script>
|
||||
|
||||
19
src/widgets/ComparisonView/ui/Search/Search.svelte
Normal file
19
src/widgets/ComparisonView/ui/Search/Search.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
Component: Search
|
||||
Typeface search input for the comparison view.
|
||||
Updates the global filterManager query to filter the font list.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { filterManager } from '$features/GetFonts';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
</script>
|
||||
|
||||
<div class="p-6 border-b border-black/5">
|
||||
<SearchBar
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="Typeface Search"
|
||||
bind:value={filterManager.queryValue}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ let {
|
||||
'flex flex-col h-full',
|
||||
'w-80',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border-r border-black/5 dark:border-white/10',
|
||||
'border-r border-subtle',
|
||||
'transition-colors duration-500',
|
||||
className,
|
||||
)}
|
||||
@@ -53,7 +53,7 @@ let {
|
||||
<div
|
||||
class="
|
||||
p-6 shrink-0
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
bg-surface dark:bg-dark-bg
|
||||
"
|
||||
>
|
||||
@@ -70,19 +70,21 @@ let {
|
||||
-->
|
||||
<ButtonGroup>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
active={comparisonStore.side === 'A'}
|
||||
onclick={() => comparisonStore.side = 'A'}
|
||||
class="flex-1 tracking-wide font-bold uppercase text-[0.625rem]"
|
||||
class="flex-1 tracking-wide font-bold uppercase"
|
||||
>
|
||||
<span>Left Font</span>
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton
|
||||
class="flex-1 tracking-wide font-bold uppercase text-[0.625rem]"
|
||||
size="sm"
|
||||
class="flex-1 tracking-wide font-bold uppercase"
|
||||
active={comparisonStore.side === 'B'}
|
||||
onclick={() => comparisonStore.side = 'B'}
|
||||
>
|
||||
<span class="uppercase">Right Font</span>
|
||||
<span>Right Font</span>
|
||||
</ToggleButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
@@ -100,7 +102,7 @@ let {
|
||||
class="
|
||||
shrink-0 p-6
|
||||
bg-surface dark:bg-dark-bg
|
||||
border-t border-black/5 dark:border-white/10
|
||||
border-t border-subtle
|
||||
z-10
|
||||
"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { TypographyMenu } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import {
|
||||
type ResponsiveManager,
|
||||
debounce,
|
||||
@@ -42,7 +44,7 @@ let { isSidebarOpen = false, class: className }: Props = $props();
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let container = $state<HTMLElement>();
|
||||
|
||||
@@ -179,12 +181,7 @@ const scaleClass = $derived(
|
||||
The paper div inside scales down when the sidebar opens on desktop.
|
||||
-->
|
||||
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<!--
|
||||
Paper surface.
|
||||
Replaces the old glassmorphism card with a clean white/dark sheet.
|
||||
Scale transition replaces motion.div spring — CSS transition-transform
|
||||
is smooth enough here; a JS spring would add ~4kb for minimal gain.
|
||||
-->
|
||||
<!-- Paper surface -->
|
||||
<div
|
||||
class={cn(
|
||||
'w-full h-full flex flex-col items-center justify-center relative',
|
||||
@@ -248,4 +245,10 @@ const scaleClass = $derived(
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypographyMenu
|
||||
class={cn(
|
||||
'absolute bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 z-50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,9 @@ import { responsiveManager } from '$shared/lib';
|
||||
export type LayoutMode = 'list' | 'grid';
|
||||
|
||||
interface LayoutConfig {
|
||||
/**
|
||||
* Active display mode (list | grid)
|
||||
*/
|
||||
mode: LayoutMode;
|
||||
}
|
||||
|
||||
@@ -40,9 +43,13 @@ const DEFAULT_CONFIG: LayoutConfig = {
|
||||
* calculation. Persists user preference to localStorage.
|
||||
*/
|
||||
class LayoutManager {
|
||||
/** Current layout mode */
|
||||
/**
|
||||
* Reactive layout mode state
|
||||
*/
|
||||
#mode = $state<LayoutMode>(DEFAULT_CONFIG.mode);
|
||||
/** Persistent storage for layout preference */
|
||||
/**
|
||||
* Persistence layer for saving layout between sessions
|
||||
*/
|
||||
#store = createPersistentStore<LayoutConfig>(STORAGE_KEY, DEFAULT_CONFIG);
|
||||
|
||||
constructor() {
|
||||
@@ -53,7 +60,9 @@ class LayoutManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Current layout mode ('list' or 'grid') */
|
||||
/**
|
||||
* Current active layout mode
|
||||
*/
|
||||
get mode(): LayoutMode {
|
||||
return this.#mode;
|
||||
}
|
||||
@@ -66,12 +75,16 @@ class LayoutManager {
|
||||
return responsiveManager.isMobile || responsiveManager.isTabletPortrait ? SM_GAP_PX : MD_GAP_PX;
|
||||
}
|
||||
|
||||
/** Whether currently in list mode */
|
||||
/**
|
||||
* True if currently showing a single-column list
|
||||
*/
|
||||
get isListMode(): boolean {
|
||||
return this.#mode === 'list';
|
||||
}
|
||||
|
||||
/** Whether currently in grid mode */
|
||||
/**
|
||||
* True if currently showing a multi-column grid
|
||||
*/
|
||||
get isGridMode(): boolean {
|
||||
return this.#mode === 'grid';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import {
|
||||
TypographyMenu,
|
||||
controlManager,
|
||||
typographySettingsStore,
|
||||
} from '$features/SetupFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
@@ -60,11 +60,11 @@ const checkPosition = throttle(() => {
|
||||
const fontRowHeight = $derived.by(() =>
|
||||
createFontRowSizeResolver({
|
||||
getFonts: () => fontStore.fonts,
|
||||
getWeight: () => controlManager.weight,
|
||||
getWeight: () => typographySettingsStore.weight,
|
||||
getPreviewText: () => text,
|
||||
getContainerWidth: () => containerWidth,
|
||||
getFontSizePx: () => controlManager.renderedSize,
|
||||
getLineHeightPx: () => controlManager.height * controlManager.renderedSize,
|
||||
getFontSizePx: () => typographySettingsStore.renderedSize,
|
||||
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
|
||||
getStatus: key => appliedFontsManager.statuses.get(key),
|
||||
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
|
||||
chromeHeight: SAMPLER_CHROME_HEIGHT,
|
||||
@@ -97,7 +97,7 @@ const fontRowHeight = $derived.by(() =>
|
||||
<FontVirtualList
|
||||
itemHeight={fontRowHeight}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
weight={typographySettingsStore.weight}
|
||||
columns={layoutManager.columns}
|
||||
gap={layoutManager.gap}
|
||||
{skeleton}
|
||||
|
||||
Reference in New Issue
Block a user