Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
8 changed files with 204 additions and 49 deletions
Showing only changes of commit 10603d18bf - Show all commits
@@ -0,0 +1,111 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock — override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -0,0 +1,33 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
+3
View File
@@ -1,3 +1,6 @@
export * from './const/const'; export * from './const/const';
export { getFontCatalog } from './store';
export * from './store'; export * from './store';
export * from './types'; export * from './types';
@@ -483,8 +483,14 @@ export class FontCatalogStore {
} }
} }
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore { let _catalog: FontCatalogStore | undefined;
return new FontCatalogStore(params);
export function getFontCatalog(): FontCatalogStore {
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
} }
export const fontCatalogStore = new FontCatalogStore({ limit: 50 }); // test-only reset, so specs don't share a live observer
export function __resetFontCatalog() {
_catalog?.destroy();
_catalog = undefined;
}
+3 -5
View File
@@ -2,11 +2,9 @@
export * from './fontLifecycleManager/fontLifecycleManager.svelte'; export * from './fontLifecycleManager/fontLifecycleManager.svelte';
// Paginated catalog // Paginated catalog
export { export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
createFontCatalogStore,
FontCatalogStore, export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
fontCatalogStore,
} from './fontCatalogStore/fontCatalogStore.svelte';
// Batch fetch by IDs (detail-cache seeding) // Batch fetch by IDs (detail-cache seeding)
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte'; export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
@@ -5,21 +5,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import { debounce } from '$shared/lib/utils'; import { debounce } from '$shared/lib/utils';
import { import { VirtualList } from '$shared/ui';
Skeleton,
VirtualList,
} from '$shared/ui';
import type { import type {
ComponentProps, ComponentProps,
Snippet, Snippet,
} from 'svelte'; } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib'; import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
fontCatalogStore,
fontLifecycleManager, fontLifecycleManager,
getFontCatalog,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
@@ -55,17 +52,27 @@ let {
...rest ...rest
}: Props = $props(); }: Props = $props();
const isLoading = $derived( const fontCatalog = getFontCatalog();
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
); const isLoading = $derived<boolean>(fontCatalog?.isLoading);
const isFetching = $derived<boolean>(fontCatalog.isFetching);
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
const total = $derived<number>(fontCatalog?.pagination.total);
let visibleFonts = $state<UnifiedFont[]>([]); let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false); let isCatchingUp = $state<boolean>(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0); const showInitialSkeleton = $derived.by(() => (
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); !!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
));
const showCatchupSkeleton = $derived.by(() => (
!!skeleton && isCatchingUp
));
// Settled query with no matches — empty state replaces the (otherwise blank) list. // Settled query with no matches — empty state replaces the (otherwise blank) list.
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0); const showEmpty = $derived.by(() => (
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
));
function handleInternalVisibleChange(items: UnifiedFont[]) { function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items; visibleFonts = items;
@@ -79,12 +86,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
* font files for thousands of intermediate fonts. * font files for thousands of intermediate fonts.
*/ */
async function handleJump(targetIndex: number) { async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) { if (isCatchingUp || !hasMore) {
return; return;
} }
isCatchingUp = true; isCatchingUp = true;
try { try {
await fontCatalogStore.fetchAllPagesTo(targetIndex); await fontCatalog.fetchAllPagesTo(targetIndex);
} finally { } finally {
isCatchingUp = false; isCatchingUp = false;
} }
@@ -105,13 +112,7 @@ $effect(() => {
if (isCatchingUp) { if (isCatchingUp) {
return; return;
} }
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => { const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
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) { if (configs.length > 0) {
debouncedTouch(configs); debouncedTouch(configs);
} }
@@ -137,13 +138,11 @@ $effect(() => {
* Load more fonts by moving to the next page * Load more fonts by moving to the next page
*/ */
function loadMore() { function loadMore() {
if ( if (!hasMore || isFetching) {
!fontCatalogStore.pagination.hasMore
|| fontCatalogStore.isFetching
) {
return; return;
} }
fontCatalogStore.nextPage();
fontCatalog.nextPage();
} }
/** /**
@@ -153,12 +152,10 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available. * of the loaded items. Only fetches if there are more pages available.
*/ */
function handleNearBottom(_lastVisibleIndex: number) { function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items. // VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it. // during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) { if (hasMore && !isFetching && !isCatchingUp) {
loadMore(); loadMore();
} }
} }
@@ -177,9 +174,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else} {:else}
<!-- VirtualList persists during pagination - no destruction/recreation --> <!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList <VirtualList
items={fontCatalogStore.fonts} items={fonts}
total={fontCatalogStore.pagination.total} {total}
isLoading={isLoading || isCatchingUp} isLoading={isLoading || isFetching || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
onJump={handleJump} onJump={handleJump}
@@ -10,8 +10,8 @@ import {
createFontRowSizeResolver, createFontRowSizeResolver,
} from '$entities/Font'; } from '$entities/Font';
import { import {
fontCatalogStore,
fontLifecycleManager, fontLifecycleManager,
getFontCatalog,
} from '$entities/Font/model'; } from '$entities/Font/model';
import { import {
TypographyMenu, TypographyMenu,
@@ -22,6 +22,8 @@ import { throttle } from '$shared/lib/utils';
import { Skeleton } from '$shared/ui'; import { Skeleton } from '$shared/ui';
import { layoutManager } from '../../model'; import { layoutManager } from '../../model';
const fontCatalog = getFontCatalog();
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte. // FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px. // Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
// Only the header is counted; the mobile footer (md:hidden) is excluded because // Only the header is counted; the mobile footer (md:hidden) is excluded because
@@ -44,14 +46,16 @@ const SAMPLER_FALLBACK_HEIGHT = 220;
*/ */
const CHECK_POSITION_THROTTLE_MS = 100; const CHECK_POSITION_THROTTLE_MS = 100;
let text = $state('The quick brown fox jumps over the lazy dog...'); const fonts = $derived(fontCatalog?.fonts);
let text = $state<string>('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null); let wrapper = $state<HTMLDivElement | null>(null);
// Binds to the actual window height // Binds to the actual window height
let innerHeight = $state(0); let innerHeight = $state<number>(0);
// Is the component above the middle of the viewport? // Is the component above the middle of the viewport?
let isAboveMiddle = $state(false); let isAboveMiddle = $state<boolean>(false);
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize. // Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
let containerWidth = $state(0); let containerWidth = $state<number>(0);
const checkPosition = throttle(() => { const checkPosition = throttle(() => {
if (!wrapper) { if (!wrapper) {
@@ -69,7 +73,7 @@ const checkPosition = throttle(() => {
// change triggers a full offsets recompute in createVirtualizer — no DOM snap. // change triggers a full offsets recompute in createVirtualizer — no DOM snap.
const fontRowHeight = $derived.by(() => const fontRowHeight = $derived.by(() =>
createFontRowSizeResolver({ createFontRowSizeResolver({
getFonts: () => fontCatalogStore.fonts, getFonts: () => fonts,
getWeight: () => typographySettingsStore.weight, getWeight: () => typographySettingsStore.weight,
getPreviewText: () => text, getPreviewText: () => text,
getContainerWidth: () => containerWidth, getContainerWidth: () => containerWidth,
@@ -3,7 +3,7 @@
Wraps SampleList with a Section component Wraps SampleList with a Section component
--> -->
<script lang="ts"> <script lang="ts">
import { fontCatalogStore } from '$entities/Font/model'; import { getFontCatalog } from '$entities/Font/model';
import { NavigationWrapper } from '$features/Breadcrumb'; import { NavigationWrapper } from '$features/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
@@ -26,6 +26,9 @@ interface Props {
const { index }: Props = $props(); const { index }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const fontCatalog = getFontCatalog();
const total = $derived<number>(fontCatalog?.pagination?.total);
</script> </script>
<NavigationWrapper index={2} title="Samples"> <NavigationWrapper index={2} title="Samples">
@@ -36,7 +39,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
id="sample_set" id="sample_set"
title="Sample Set" title="Sample Set"
headerTitle="visual_output" headerTitle="visual_output"
headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}" headerSubtitle="items_total: {total ?? 0}"
headerAction={registerAction} headerAction={registerAction}
> >
{#snippet headerContent()} {#snippet headerContent()}