Compare commits
13 Commits
b390efdabe
...
c09ca93f4e
| Author | SHA1 | Date | |
|---|---|---|---|
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c |
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css",
|
"*.css",
|
||||||
"**/router.ts",
|
"**/router.ts"
|
||||||
"**/bindings.svelte.ts"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
descendants of this provider.
|
descendants of this provider.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// First call to the lazy singleton — constructs the shared client for the app.
|
||||||
|
const queryClient = getQueryClient();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { FontResponseError } from '../../lib/errors/errors';
|
import { FontResponseError } from '../../lib/errors/errors';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
import type { QueryParams } from '$shared/lib/utils';
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
|||||||
*/
|
*/
|
||||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||||
fonts.forEach(font => {
|
fonts.forEach(font => {
|
||||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type { FontRowSizeResolverOptions } from './lib';
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
FontSampler,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|
||||||
|
|||||||
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
|
|||||||
*/
|
*/
|
||||||
const { QueryClient } = await import('@tanstack/query-core');
|
const { QueryClient } = await import('@tanstack/query-core');
|
||||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
|
const mockClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
queryClient: new QueryClient({
|
getQueryClient: () => mockClient,
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fetchProxyFonts } from '../../../api';
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_QUERY_GC_TIME_MS,
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
DEFAULT_QUERY_STALE_TIME_MS,
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
queryClient,
|
getQueryClient,
|
||||||
} from '$shared/api/queryClient';
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
InfiniteQueryObserver,
|
InfiniteQueryObserver,
|
||||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
|||||||
readonly unknown[],
|
readonly unknown[],
|
||||||
PageParam
|
PageParam
|
||||||
>;
|
>;
|
||||||
#qc = queryClient;
|
#qc = getQueryClient();
|
||||||
#unsubscribe: () => void;
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
constructor(params: FontStoreParams = {}) {
|
constructor(params: FontStoreParams = {}) {
|
||||||
@@ -483,14 +484,12 @@ export class FontCatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _catalog: FontCatalogStore | undefined;
|
const catalog = createSingleton(
|
||||||
|
() => new FontCatalogStore({ limit: 50 }),
|
||||||
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
export function getFontCatalog(): FontCatalogStore {
|
export const getFontCatalog = catalog.get;
|
||||||
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// test-only reset, so specs don't share a live observer
|
// test-only reset, so specs don't share a live observer
|
||||||
export function __resetFontCatalog() {
|
export const __resetFontCatalog = catalog.reset;
|
||||||
_catalog?.destroy();
|
|
||||||
_catalog = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
@@ -419,18 +420,16 @@ export class FontLifecycleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _fontLifecycleManager: FontLifecycleManager | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||||
*/
|
*/
|
||||||
export function getFontLifecycleManager(): FontLifecycleManager {
|
const fontLifecycleManager = createSingleton(
|
||||||
return (_fontLifecycleManager ??= new FontLifecycleManager());
|
() => new FontLifecycleManager(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share loaded-font/eviction state
|
// test-only reset, so specs don't share loaded-font/eviction state
|
||||||
export function __resetFontLifecycleManager() {
|
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||||
_fontLifecycleManager?.destroy();
|
|
||||||
_fontLifecycleManager = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock font data: factory functions and preset fixtures.
|
||||||
* MOCK FONT DATA
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for fonts.
|
|
||||||
* Used in Storybook stories, tests, and development.
|
* Used in Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock data helpers (main export).
|
||||||
* MOCK DATA HELPERS - MAIN EXPORT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Quick Start
|
* ## Quick Start
|
||||||
|
|||||||
+12
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
|||||||
import FontSampler from './FontSampler.svelte';
|
import FontSampler from './FontSampler.svelte';
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
const { Story } = defineMeta({
|
||||||
title: 'Features/FontSampler',
|
title: 'Entities/Font/FontSampler',
|
||||||
component: FontSampler,
|
component: FontSampler,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -39,8 +39,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
|
||||||
// Mock fonts for testing
|
// Mock fonts for testing
|
||||||
const mockArial: UnifiedFont = {
|
const mockArial: UnifiedFont = {
|
||||||
@@ -84,6 +84,14 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
isVariable: false,
|
isVariable: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||||
|
const mockTypography = {
|
||||||
|
renderedSize: 48,
|
||||||
|
weight: 400,
|
||||||
|
height: 1.5,
|
||||||
|
spacing: 0,
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -93,6 +101,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
text: 'The quick brown fox jumps over the lazy dog',
|
text: 'The quick brown fox jumps over the lazy dog',
|
||||||
index: 0,
|
index: 0,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
@@ -111,6 +120,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
text:
|
text:
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||||
index: 1,
|
index: 1,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
+45
-21
@@ -4,12 +4,6 @@
|
|||||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
|
||||||
FontApplicator,
|
|
||||||
type FontLoadStatus,
|
|
||||||
type UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -18,6 +12,35 @@ import {
|
|||||||
Stat,
|
Stat,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
import type {
|
||||||
|
FontLoadStatus,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model/types';
|
||||||
|
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal typography contract this view renders with. The AdjustTypography
|
||||||
|
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||||
|
* from that feature (no entity -> feature import).
|
||||||
|
*/
|
||||||
|
interface FontSampleTypography {
|
||||||
|
/**
|
||||||
|
* Rendered font size in px
|
||||||
|
*/
|
||||||
|
renderedSize: number;
|
||||||
|
/**
|
||||||
|
* Numeric font weight
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Line-height multiplier
|
||||||
|
*/
|
||||||
|
height: number;
|
||||||
|
/**
|
||||||
|
* Letter spacing
|
||||||
|
*/
|
||||||
|
spacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -39,11 +62,15 @@ interface Props {
|
|||||||
* @default 0
|
* @default 0
|
||||||
*/
|
*/
|
||||||
index?: number;
|
index?: number;
|
||||||
|
/**
|
||||||
|
* Typography settings to render the sample with. Injected by the composing
|
||||||
|
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||||
|
* decoupled from that feature — the same inversion as `status`.
|
||||||
|
*/
|
||||||
|
typography: FontSampleTypography;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { font, status, text = $bindable(), index = 0 }: Props = $props();
|
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||||
|
|
||||||
const typographySettingsStore = getTypographySettingsStore();
|
|
||||||
|
|
||||||
// Extract provider badge with fallback
|
// Extract provider badge with fallback
|
||||||
const providerBadge = $derived(
|
const providerBadge = $derived(
|
||||||
@@ -52,10 +79,10 @@ const providerBadge = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stats = $derived([
|
const stats = $derived([
|
||||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
{ label: 'WGT', value: `${typography.weight}` },
|
||||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
{ label: 'LTR', value: `${typography.spacing}` },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,9 +100,8 @@ const stats = $derived([
|
|||||||
min-h-60
|
min-h-60
|
||||||
rounded-none
|
rounded-none
|
||||||
"
|
"
|
||||||
style:font-weight={typographySettingsStore.weight}
|
style:font-weight={typography.weight}
|
||||||
>
|
>
|
||||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
@@ -136,19 +162,18 @@ const stats = $derived([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── 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">
|
<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} {status}>
|
<FontApplicator {font} {status}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
fontSize={typographySettingsStore.renderedSize}
|
fontSize={typography.renderedSize}
|
||||||
lineHeight={typographySettingsStore.height}
|
lineHeight={typography.height}
|
||||||
letterSpacing={typographySettingsStore.spacing}
|
letterSpacing={typography.spacing}
|
||||||
/>
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||||
<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">
|
<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}
|
{#each stats as stat, i}
|
||||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||||
@@ -160,7 +185,6 @@ const stats = $derived([
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
absolute bottom-0 left-0 right-0
|
absolute bottom-0 left-0 right-0
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
FontSampler,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-13
@@ -15,10 +15,14 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
} from '$entities/Font';
|
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||||
|
// constants, not the entity's UI/store graph (+ @tanstack) — keeps this
|
||||||
|
// feature store and its spec light at import. See audit D-1.
|
||||||
|
} from '$entities/Font/model/const/const';
|
||||||
import {
|
import {
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
createSingleton,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type { NumericControl } from '$shared/ui';
|
import type { NumericControl } from '$shared/ui';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
@@ -166,6 +170,7 @@ export class TypographySettingsStore {
|
|||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.#disposeEffects();
|
this.#disposeEffects();
|
||||||
|
this.#storage.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,22 +353,17 @@ export function createTypographySettingsStore(
|
|||||||
|
|
||||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||||
|
|
||||||
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide typography settings store, keyed for the comparison view.
|
* App-wide typography settings store, keyed for the comparison view.
|
||||||
* Created on first access so its persistent-store sync effects aren't set up
|
* Created on first access so its persistent-store sync effects aren't set up
|
||||||
* at module load.
|
* at module load.
|
||||||
*/
|
*/
|
||||||
export function getTypographySettingsStore(): TypographySettingsStoreInstance {
|
const typographySettingsStore = createSingleton(
|
||||||
return (_typographySettingsStore ??= createTypographySettingsStore(
|
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
instance => instance.destroy(),
|
||||||
COMPARISON_STORAGE_KEY,
|
);
|
||||||
));
|
|
||||||
}
|
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||||
export function __resetTypographySettingsStore() {
|
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||||
_typographySettingsStore?.destroy();
|
|
||||||
_typographySettingsStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font/model/const/const';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
mockStorage = v;
|
mockStorage = v;
|
||||||
},
|
},
|
||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographySettingsStore(
|
const manager = new TypographySettingsStore(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll-based breadcrumb tracking store
|
* Scroll-based breadcrumb tracking store
|
||||||
*
|
*
|
||||||
@@ -279,17 +281,15 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
|||||||
return new ScrollBreadcrumbsStore();
|
return new ScrollBreadcrumbsStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide scroll breadcrumbs store, created on first access.
|
* App-wide scroll breadcrumbs store, created on first access.
|
||||||
*/
|
*/
|
||||||
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
const scrollBreadcrumbsStore = createSingleton(
|
||||||
return (_scrollBreadcrumbsStore ??= createScrollBreadcrumbsStore());
|
() => createScrollBreadcrumbsStore(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share observer/scroll state
|
// test-only reset, so specs don't share observer/scroll state
|
||||||
export function __resetScrollBreadcrumbsStore() {
|
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||||
_scrollBreadcrumbsStore?.destroy();
|
|
||||||
_scrollBreadcrumbsStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import {
|
||||||
|
createPersistentStore,
|
||||||
|
createSingleton,
|
||||||
|
} from '$shared/lib';
|
||||||
|
|
||||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||||
this.#mediaQuery = null;
|
this.#mediaQuery = null;
|
||||||
|
this.#store.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,23 +198,18 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _themeManager: ThemeManager | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide theme manager, created on first access.
|
* App-wide theme manager, created on first access.
|
||||||
*
|
*
|
||||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||||
* Call init() on mount and destroy() on unmount (see Layout).
|
* Call init() on mount and destroy() on unmount (see Layout).
|
||||||
*/
|
*/
|
||||||
export function getThemeManager(): ThemeManager {
|
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||||
return (_themeManager ??= new ThemeManager());
|
|
||||||
}
|
export const getThemeManager = themeManager.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share persisted theme state
|
// test-only reset, so specs don't share persisted theme state
|
||||||
export function __resetThemeManager() {
|
export const __resetThemeManager = themeManager.reset;
|
||||||
_themeManager?.destroy();
|
|
||||||
_themeManager = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeManager class exported for testing purposes
|
* ThemeManager class exported for testing purposes
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { FontSampler } from './ui';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
|
||||||
|
|
||||||
export { FontSampler };
|
|
||||||
+11
-10
@@ -23,7 +23,10 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createFilter } from '$shared/lib';
|
import {
|
||||||
|
createFilter,
|
||||||
|
createSingleton,
|
||||||
|
} from '$shared/lib';
|
||||||
import { createDebouncedState } from '$shared/lib/helpers';
|
import { createDebouncedState } from '$shared/lib/helpers';
|
||||||
import type {
|
import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
@@ -129,8 +132,6 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
|||||||
|
|
||||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||||
|
|
||||||
let _appliedFilterStore: AppliedFilterStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide filter manager, created on first access.
|
* App-wide filter manager, created on first access.
|
||||||
*
|
*
|
||||||
@@ -138,14 +139,14 @@ let _appliedFilterStore: AppliedFilterStore | undefined;
|
|||||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||||
* metadata arrives.
|
* metadata arrives.
|
||||||
*/
|
*/
|
||||||
export function getAppliedFilterStore(): AppliedFilterStore {
|
const appliedFilterStore = createSingleton(() =>
|
||||||
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
|
createAppliedFilterStore<string>({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: [],
|
groups: [],
|
||||||
}));
|
})
|
||||||
}
|
);
|
||||||
|
|
||||||
|
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share filter/selection state
|
// test-only reset, so specs don't share filter/selection state
|
||||||
export function __resetAppliedFilterStore() {
|
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||||
_appliedFilterStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-11
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
|||||||
import {
|
import {
|
||||||
DEFAULT_QUERY_GC_TIME_MS,
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
DEFAULT_QUERY_STALE_TIME_MS,
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
queryClient,
|
getQueryClient,
|
||||||
} from '$shared/api/queryClient';
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
|||||||
/**
|
/**
|
||||||
* Shared query client
|
* Shared query client
|
||||||
*/
|
*/
|
||||||
protected qc = queryClient;
|
protected qc = getQueryClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new filters store
|
* Creates a new filters store
|
||||||
@@ -126,18 +127,16 @@ export class AvailableFilterStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _availableFilterStore: AvailableFilterStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide filter-metadata store, created on first access. Lazy so the
|
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||||
* QueryObserver isn't constructed at module load.
|
* QueryObserver isn't constructed at module load.
|
||||||
*/
|
*/
|
||||||
export function getAvailableFilterStore(): AvailableFilterStore {
|
const availableFilterStore = createSingleton(
|
||||||
return (_availableFilterStore ??= new AvailableFilterStore());
|
() => new AvailableFilterStore(),
|
||||||
}
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAvailableFilterStore = availableFilterStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share a live observer
|
// test-only reset, so specs don't share a live observer
|
||||||
export function __resetAvailableFilterStore() {
|
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||||
_availableFilterStore?.destroy();
|
|
||||||
_availableFilterStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort store — manages the current sort option for font listings.
|
* Sort store — manages the current sort option for font listings.
|
||||||
*
|
*
|
||||||
@@ -46,16 +48,12 @@ export function createSortStore(initial: SortOption = 'Popularity') {
|
|||||||
|
|
||||||
export type SortStore = ReturnType<typeof createSortStore>;
|
export type SortStore = ReturnType<typeof createSortStore>;
|
||||||
|
|
||||||
let _sortStore: SortStore | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide sort store, created on first access.
|
* App-wide sort store, created on first access.
|
||||||
*/
|
*/
|
||||||
export function getSortStore(): SortStore {
|
const sortStore = createSingleton(() => createSortStore());
|
||||||
return (_sortStore ??= createSortStore());
|
|
||||||
}
|
export const getSortStore = sortStore.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share selection state
|
// test-only reset, so specs don't share selection state
|
||||||
export function __resetSortStore() {
|
export const __resetSortStore = sortStore.reset;
|
||||||
_sortStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
|||||||
*/
|
*/
|
||||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||||
|
|
||||||
|
let queryClientInstance: QueryClient | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TanStack Query client instance
|
* Shared TanStack Query client (lazy singleton).
|
||||||
*
|
*
|
||||||
* Configured for optimal caching and refetching behavior.
|
* Construction is deferred to the first call so importing this module is inert:
|
||||||
* Used by all font stores for data fetching and caching.
|
* module eval runs no `new QueryClient()`, so the module is genuinely
|
||||||
|
* side-effect-free and needs no `sideEffects` allowlist exception. The
|
||||||
|
* app-layer `QueryProvider` is the first caller; every store reuses the same
|
||||||
|
* instance. Matches the lazy-accessor pattern used by the font stores.
|
||||||
*
|
*
|
||||||
* Cache behavior:
|
* Cache behavior:
|
||||||
* - Data stays fresh for 5 minutes (staleTime)
|
* - Data stays fresh for 5 minutes (staleTime)
|
||||||
@@ -39,30 +44,32 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
|||||||
* - No refetch on window focus (reduces unnecessary network requests)
|
* - No refetch on window focus (reduces unnecessary network requests)
|
||||||
* - 3 retries with exponential backoff on failure
|
* - 3 retries with exponential backoff on failure
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export function getQueryClient(): QueryClient {
|
||||||
defaultOptions: {
|
return (queryClientInstance ??= new QueryClient({
|
||||||
queries: {
|
defaultOptions: {
|
||||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
queries: {
|
||||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
/**
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
* Don't refetch when window regains focus
|
/**
|
||||||
*/
|
* Don't refetch when window regains focus
|
||||||
refetchOnWindowFocus: false,
|
*/
|
||||||
/**
|
refetchOnWindowFocus: false,
|
||||||
* Refetch on mount if data is stale
|
/**
|
||||||
*/
|
* Refetch on mount if data is stale
|
||||||
refetchOnMount: true,
|
*/
|
||||||
retry: (failureCount, error) => {
|
refetchOnMount: true,
|
||||||
if (error instanceof NonRetryableError) {
|
retry: (failureCount, error) => {
|
||||||
return false;
|
if (error instanceof NonRetryableError) {
|
||||||
}
|
return false;
|
||||||
return failureCount < QUERY_RETRY_COUNT;
|
}
|
||||||
|
return failureCount < QUERY_RETRY_COUNT;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||||
|
*/
|
||||||
|
retryDelay: attemptIndex =>
|
||||||
|
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
|
||||||
*/
|
|
||||||
retryDelay: attemptIndex =>
|
|
||||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
|
||||||
},
|
},
|
||||||
},
|
}));
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
type QueryObserverOptions,
|
type QueryObserverOptions,
|
||||||
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
|
|||||||
#unsubscribe: () => void;
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||||
this.#observer = new QueryObserver(queryClient, options);
|
this.#observer = new QueryObserver(getQueryClient(), options);
|
||||||
this.#unsubscribe = this.#observer.subscribe(result => {
|
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||||
this.#result = result;
|
this.#result = result;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
|
|||||||
@@ -1,66 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Persistent localStorage-backed reactive state
|
* Reactive localStorage-backed state. Loads on init, saves on change via an
|
||||||
|
* $effect.root. Falls back to the default on SSR (no localStorage) and on JSON
|
||||||
|
* parse errors; swallows quota/write errors with a warning.
|
||||||
*
|
*
|
||||||
* Creates reactive state that automatically syncs with localStorage.
|
* Owners that create this outside a component must call destroy() to dispose
|
||||||
* Values persist across browser sessions and are restored on page load.
|
* the save effect.
|
||||||
*
|
*
|
||||||
* Handles edge cases:
|
* @param key - localStorage key
|
||||||
* - SSR safety (no localStorage on server)
|
* @param defaultValue - value used when nothing is stored
|
||||||
* - JSON parse errors (falls back to default)
|
|
||||||
* - Storage quota errors (logs warning, doesn't crash)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Store user preferences
|
|
||||||
* const preferences = createPersistentStore('user-prefs', {
|
|
||||||
* theme: 'dark',
|
|
||||||
* fontSize: 16,
|
|
||||||
* sidebarOpen: true
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Access reactive state
|
|
||||||
* $: currentTheme = preferences.value.theme;
|
|
||||||
*
|
|
||||||
* // Update (auto-saves to localStorage)
|
|
||||||
* preferences.value.theme = 'light';
|
|
||||||
*
|
|
||||||
* // Clear stored value
|
|
||||||
* preferences.clear();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reactive store backed by localStorage
|
|
||||||
*
|
|
||||||
* The value is loaded from localStorage on initialization and automatically
|
|
||||||
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
|
|
||||||
*
|
|
||||||
* @param key - localStorage key for storing the value
|
|
||||||
* @param defaultValue - Default value if no stored value exists
|
|
||||||
* @returns Persistent store with getter/setter and clear method
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Simple value
|
|
||||||
* const counter = createPersistentStore('counter', 0);
|
|
||||||
* counter.value++;
|
|
||||||
*
|
|
||||||
* // Complex object
|
|
||||||
* interface Settings {
|
|
||||||
* theme: 'light' | 'dark';
|
|
||||||
* fontSize: number;
|
|
||||||
* }
|
|
||||||
* const settings = createPersistentStore<Settings>('app-settings', {
|
|
||||||
* theme: 'light',
|
|
||||||
* fontSize: 16
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||||
/**
|
|
||||||
* Load value from localStorage or return default
|
|
||||||
* Safely handles missing keys, parse errors, and SSR
|
|
||||||
*/
|
|
||||||
const loadFromStorage = (): T => {
|
const loadFromStorage = (): T => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
|
|
||||||
let value = $state<T>(loadFromStorage());
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
// Sync to storage whenever value changes
|
/**
|
||||||
// Wrapped in $effect.root to prevent memory leaks
|
* Sync to storage whenever value changes. The effect lives in an
|
||||||
$effect.root(() => {
|
* $effect.root so it outlives any component; the returned disposer is kept
|
||||||
|
* and run by destroy(), because an $effect.root with no disposer leaks for
|
||||||
|
* the life of the process.
|
||||||
|
*/
|
||||||
|
const dispose = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
}
|
}
|
||||||
value = defaultValue;
|
value = defaultValue;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose the storage-sync effect. Owners that create a store outside a
|
||||||
|
* component (e.g. a singleton store class) must call this to avoid
|
||||||
|
* leaking the underlying $effect.root.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
dispose();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
|
|||||||
expect(store.value[0].name).toBe('First');
|
expect(store.value[0].name).toBe('First');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Lifecycle', () => {
|
||||||
|
it('persists value changes via the sync effect', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'a');
|
||||||
|
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||||
|
|
||||||
|
store.value = 'b';
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(testKey, JSON.stringify('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops persisting after destroy()', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'a');
|
||||||
|
flushSync();
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||||
|
store.value = 'c';
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
// reading still works after disposal
|
||||||
|
expect(store.value).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy() is safe to call repeatedly', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'a');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
store.destroy();
|
||||||
|
store.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createSingleton } from './createSingleton';
|
||||||
|
|
||||||
|
describe('createSingleton', () => {
|
||||||
|
it('does not call the factory until the first get (lazy)', () => {
|
||||||
|
const factory = vi.fn(() => ({ id: 1 }));
|
||||||
|
createSingleton(factory);
|
||||||
|
expect(factory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs on first get and memoizes the instance', () => {
|
||||||
|
const factory = vi.fn(() => ({ id: 1 }));
|
||||||
|
const singleton = createSingleton(factory);
|
||||||
|
|
||||||
|
const a = singleton.get();
|
||||||
|
const b = singleton.get();
|
||||||
|
|
||||||
|
expect(factory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds a fresh instance after reset', () => {
|
||||||
|
let count = 0;
|
||||||
|
const singleton = createSingleton(() => ({ id: ++count }));
|
||||||
|
|
||||||
|
const first = singleton.get();
|
||||||
|
singleton.reset();
|
||||||
|
const second = singleton.get();
|
||||||
|
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
expect(second.id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs teardown once, with the live instance, on reset', () => {
|
||||||
|
const teardown = vi.fn();
|
||||||
|
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||||
|
|
||||||
|
const instance = singleton.get();
|
||||||
|
singleton.reset();
|
||||||
|
|
||||||
|
expect(teardown).toHaveBeenCalledTimes(1);
|
||||||
|
expect(teardown).toHaveBeenCalledWith(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats reset before any get as a no-op (no teardown, no throw)', () => {
|
||||||
|
const teardown = vi.fn();
|
||||||
|
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||||
|
|
||||||
|
expect(() => singleton.reset()).not.toThrow();
|
||||||
|
expect(teardown).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run teardown again on a second consecutive reset', () => {
|
||||||
|
const teardown = vi.fn();
|
||||||
|
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||||
|
|
||||||
|
singleton.get();
|
||||||
|
singleton.reset();
|
||||||
|
singleton.reset();
|
||||||
|
|
||||||
|
expect(teardown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without a teardown', () => {
|
||||||
|
const singleton = createSingleton(() => ({ id: 1 }));
|
||||||
|
|
||||||
|
singleton.get();
|
||||||
|
expect(() => singleton.reset()).not.toThrow();
|
||||||
|
expect(singleton.get().id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches a falsy instance value without re-running the factory', () => {
|
||||||
|
const factory = vi.fn(() => undefined);
|
||||||
|
const singleton = createSingleton<undefined>(factory);
|
||||||
|
|
||||||
|
singleton.get();
|
||||||
|
singleton.get();
|
||||||
|
|
||||||
|
expect(factory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* A lazily-constructed singleton accessor pair.
|
||||||
|
*/
|
||||||
|
export interface Singleton<T> {
|
||||||
|
/**
|
||||||
|
* Returns the instance, constructing it on the first call and reusing it
|
||||||
|
* thereafter.
|
||||||
|
*/
|
||||||
|
get: () => T;
|
||||||
|
/**
|
||||||
|
* Tears down the current instance (if built) and clears it, so the next
|
||||||
|
* `get()` rebuilds. Used by specs to avoid shared state between tests.
|
||||||
|
*/
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardizes the lazy `getX()` / `__resetX()` singleton pattern used by the
|
||||||
|
* app's stores.
|
||||||
|
*
|
||||||
|
* The instance is built on the first `get()` and reused afterwards; `reset()`
|
||||||
|
* runs the optional teardown against the live instance and clears it. Building
|
||||||
|
* lazily keeps the owning module inert at import — construction happens only on
|
||||||
|
* first access, never at module eval.
|
||||||
|
*
|
||||||
|
* @param factory - Builds the instance on first access.
|
||||||
|
* @param teardown - Optional cleanup run against the live instance on reset
|
||||||
|
* (e.g. disposing an `$effect.root` via the instance's `destroy()`).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const catalog = createSingleton(() => new FontCatalogStore({ limit: 50 }), c => c.destroy());
|
||||||
|
* export const getFontCatalog = catalog.get;
|
||||||
|
* export const __resetFontCatalog = catalog.reset;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createSingleton<T>(factory: () => T, teardown?: (instance: T) => void): Singleton<T> {
|
||||||
|
let instance: T | undefined;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => {
|
||||||
|
if (!initialized) {
|
||||||
|
instance = factory();
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return instance as T;
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
if (initialized) {
|
||||||
|
teardown?.(instance as T);
|
||||||
|
}
|
||||||
|
instance = undefined;
|
||||||
|
initialized = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -137,6 +137,20 @@ export {
|
|||||||
type PerspectiveManager,
|
type PerspectiveManager,
|
||||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy singletons
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* Lazy `getX()` / `__resetX()` singleton accessor factory
|
||||||
|
*/
|
||||||
|
createSingleton,
|
||||||
|
/**
|
||||||
|
* Singleton accessor pair type
|
||||||
|
*/
|
||||||
|
type Singleton,
|
||||||
|
} from './createSingleton/createSingleton';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* BaseQueryStore is intentionally NOT re-exported here.
|
* BaseQueryStore is intentionally NOT re-exported here.
|
||||||
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
createPerspectiveManager,
|
createPerspectiveManager,
|
||||||
createResponsiveManager,
|
createResponsiveManager,
|
||||||
|
createSingleton,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
@@ -21,6 +22,7 @@ export {
|
|||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
type Singleton,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Storybook helpers: components and utilities for stories.
|
||||||
* STORYBOOK HELPERS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Helper components and utilities for Storybook stories.
|
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import {
|
import {
|
||||||
type LabelSize,
|
type LabelSize,
|
||||||
labelSizeConfig,
|
labelSizeConfig,
|
||||||
} from '$shared/ui/Label/config';
|
} from '../labelConfig';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
{formattedValue()}
|
{formattedValue()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<!--
|
||||||
|
FULL MODE
|
||||||
|
+/- buttons flanking a slider popover.
|
||||||
|
-->
|
||||||
<div class={cn('flex items-center px-1 relative', className)}>
|
<div class={cn('flex items-center px-1 relative', className)}>
|
||||||
<!-- Decrease button -->
|
<!-- Decrease button -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as Input } from './Input.svelte';
|
export { default as Input } from './Input.svelte';
|
||||||
|
export { inputIconSize } from './types';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type LabelVariant,
|
type LabelVariant,
|
||||||
labelSizeConfig,
|
labelSizeConfig,
|
||||||
labelVariantConfig,
|
labelVariantConfig,
|
||||||
} from './config';
|
} from '../labelConfig';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
They cannot pass leftIcon — it's owned by this wrapper.
|
They cannot pass leftIcon — it's owned by this wrapper.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input } from '$shared/ui/Input';
|
import {
|
||||||
import { inputIconSize } from '$shared/ui/Input/types';
|
Input,
|
||||||
|
inputIconSize,
|
||||||
|
} from '$shared/ui/Input';
|
||||||
import SearchIcon from '@lucide/svelte/icons/search';
|
import SearchIcon from '@lucide/svelte/icons/search';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function close() {
|
|||||||
|
|
||||||
{#if responsive.isMobile}
|
{#if responsive.isMobile}
|
||||||
<!--
|
<!--
|
||||||
── MOBILE: fixed overlay ─────────────────────────────────────────────
|
MOBILE: fixed overlay.
|
||||||
Only rendered when open. Both backdrop and panel use Svelte transitions
|
Only rendered when open. Both backdrop and panel use Svelte transitions
|
||||||
so they animate in and out independently.
|
so they animate in and out independently.
|
||||||
-->
|
-->
|
||||||
@@ -70,7 +70,7 @@ function close() {
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!--
|
<!--
|
||||||
── DESKTOP: collapsible column ───────────────────────────────────────
|
DESKTOP: collapsible column.
|
||||||
Always in the DOM — width transitions between 320px and 0.
|
Always in the DOM — width transitions between 320px and 0.
|
||||||
overflow-hidden clips the w-80 inner div during the collapse.
|
overflow-hidden clips the w-80 inner div during the collapse.
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import {
|
||||||
type LabelSize,
|
type LabelSize,
|
||||||
type LabelVariant,
|
type LabelVariant,
|
||||||
labelSizeConfig,
|
labelSizeConfig,
|
||||||
labelVariantConfig,
|
labelVariantConfig,
|
||||||
} from '$shared/ui/Label/config';
|
} from '../labelConfig';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
type TypographySettingsStore,
|
type TypographySettingsStore,
|
||||||
getTypographySettingsStore,
|
getTypographySettingsStore,
|
||||||
} from '$features/AdjustTypography/model';
|
} from '$features/AdjustTypography';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import {
|
||||||
|
createPersistentStore,
|
||||||
|
createSingleton,
|
||||||
|
} from '$shared/lib';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { getPretextFontString } from '../../lib';
|
import { getPretextFontString } from '../../lib';
|
||||||
|
|
||||||
@@ -56,12 +59,6 @@ const STORAGE_KEY = 'glyphdiff:comparison';
|
|||||||
*/
|
*/
|
||||||
const FONT_READY_FALLBACK_MS = 1000;
|
const FONT_READY_FALLBACK_MS = 1000;
|
||||||
|
|
||||||
// Persistent storage for selected comparison fonts
|
|
||||||
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
|
||||||
fontAId: null,
|
|
||||||
fontBId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store for managing font comparison state.
|
* Store for managing font comparison state.
|
||||||
*
|
*
|
||||||
@@ -100,22 +97,39 @@ export class ComparisonStore {
|
|||||||
* TanStack Query-backed store for efficient batch font retrieval
|
* TanStack Query-backed store for efficient batch font retrieval
|
||||||
*/
|
*/
|
||||||
#fontsByIdsStore: FontsByIdsStore;
|
#fontsByIdsStore: FontsByIdsStore;
|
||||||
|
/**
|
||||||
|
* Paginated font catalog — source of fonts for default seeding.
|
||||||
|
*/
|
||||||
#fontCatalog: FontCatalogStore;
|
#fontCatalog: FontCatalogStore;
|
||||||
|
/**
|
||||||
|
* Typography settings applied to the rendered comparison.
|
||||||
|
*/
|
||||||
#typography: TypographySettingsStore;
|
#typography: TypographySettingsStore;
|
||||||
|
/**
|
||||||
|
* Font load/cache/eviction manager; pinned to keep compared fonts resident.
|
||||||
|
*/
|
||||||
#lifecycle: FontLifecycleManager;
|
#lifecycle: FontLifecycleManager;
|
||||||
|
/**
|
||||||
|
* Per-instance persistent storage for the selected comparison fonts.
|
||||||
|
*/
|
||||||
|
#storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||||
|
fontAId: null,
|
||||||
|
fontBId: null,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Disposes the constructor's $effect.root. Must be run on teardown.
|
||||||
|
*/
|
||||||
|
#disposeEffects: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Synchronously seed the batch store with any IDs already in storage
|
// Synchronously seed the batch store with any IDs already in storage
|
||||||
const { fontAId, fontBId } = storage.value;
|
const { fontAId, fontBId } = this.#storage.value;
|
||||||
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||||
this.#fontCatalog = getFontCatalog();
|
this.#fontCatalog = getFontCatalog();
|
||||||
this.#typography = getTypographySettingsStore();
|
this.#typography = getTypographySettingsStore();
|
||||||
this.#lifecycle = getFontLifecycleManager();
|
this.#lifecycle = getFontLifecycleManager();
|
||||||
|
|
||||||
$effect.root(() => {
|
this.#disposeEffects = $effect.root(() => {
|
||||||
// Sync batch results → fontA / fontB
|
// Sync batch results → fontA / fontB
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const fonts = this.#fontsByIdsStore.fonts;
|
const fonts = this.#fontsByIdsStore.fonts;
|
||||||
@@ -123,7 +137,7 @@ export class ComparisonStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fontAId: aId, fontBId: bId } = storage.value;
|
const { fontAId: aId, fontBId: bId } = this.#storage.value;
|
||||||
if (aId) {
|
if (aId) {
|
||||||
const fa = fonts.find(f => f.id === aId);
|
const fa = fonts.find(f => f.id === aId);
|
||||||
if (fa) {
|
if (fa) {
|
||||||
@@ -178,7 +192,7 @@ export class ComparisonStore {
|
|||||||
// Untracked: only the catalog load should drive this effect, not the
|
// Untracked: only the catalog load should drive this effect, not the
|
||||||
// user's storage writes that happen as a result of normal selection.
|
// user's storage writes that happen as a result of normal selection.
|
||||||
const hasStoredSelection = untrack(() => {
|
const hasStoredSelection = untrack(() => {
|
||||||
return storage.value.fontAId !== null || storage.value.fontBId !== null;
|
return this.#storage.value.fontAId !== null || this.#storage.value.fontBId !== null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasStoredSelection) {
|
if (hasStoredSelection) {
|
||||||
@@ -194,7 +208,7 @@ export class ComparisonStore {
|
|||||||
untrack(() => {
|
untrack(() => {
|
||||||
const id1 = fonts[0].id;
|
const id1 = fonts[0].id;
|
||||||
const id2 = fonts[fonts.length - 1].id;
|
const id2 = fonts[fonts.length - 1].id;
|
||||||
storage.value = { fontAId: id1, fontBId: id2 };
|
this.#storage.value = { fontAId: id1, fontBId: id2 };
|
||||||
this.#fontsByIdsStore.setIds([id1, id2]);
|
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -279,7 +293,7 @@ export class ComparisonStore {
|
|||||||
* Updates persistent storage with the current font selection.
|
* Updates persistent storage with the current font selection.
|
||||||
*/
|
*/
|
||||||
private updateStorage() {
|
private updateStorage() {
|
||||||
storage.value = {
|
this.#storage.value = {
|
||||||
fontAId: this.#fontA?.id ?? null,
|
fontAId: this.#fontA?.id ?? null,
|
||||||
fontBId: this.#fontB?.id ?? null,
|
fontBId: this.#fontB?.id ?? null,
|
||||||
};
|
};
|
||||||
@@ -363,19 +377,28 @@ export class ComparisonStore {
|
|||||||
this.#fontA = undefined;
|
this.#fontA = undefined;
|
||||||
this.#fontB = undefined;
|
this.#fontB = undefined;
|
||||||
this.#fontsByIdsStore.setIds([]);
|
this.#fontsByIdsStore.setIds([]);
|
||||||
storage.clear();
|
this.#storage.clear();
|
||||||
this.#typography.reset();
|
this.#typography.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes reactive effects and the persistent store. Call on teardown.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.#disposeEffects();
|
||||||
|
this.#storage.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _comparisonStore: ComparisonStore | undefined;
|
const comparisonStore = createSingleton(
|
||||||
|
() => new ComparisonStore(),
|
||||||
|
instance => {
|
||||||
|
instance.resetAll();
|
||||||
|
instance.destroy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export function getComparisonStore(): ComparisonStore {
|
export const getComparisonStore = comparisonStore.get;
|
||||||
return (_comparisonStore ??= new ComparisonStore());
|
|
||||||
}
|
|
||||||
|
|
||||||
// test-only reset, so specs don't share a live observer
|
// test-only reset, so specs don't share a live observer or persisted state
|
||||||
export function __resetComparisonStore() {
|
export const __resetComparisonStore = comparisonStore.reset;
|
||||||
_comparisonStore?.resetAll();
|
|
||||||
_comparisonStore = undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
|
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
import type { UnifiedFont } from '$entities/Font';
|
||||||
import { UNIFIED_FONTS } from '$entities/Font/testing';
|
import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -44,6 +46,8 @@ const mockStorage = vi.hoisted(() => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
storage.destroy = vi.fn();
|
||||||
|
|
||||||
return storage;
|
return storage;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +86,12 @@ vi.mock('$entities/Font/model', async importOriginal => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockTypography = vi.hoisted(() => ({
|
||||||
|
weight: 400,
|
||||||
|
renderedSize: 48,
|
||||||
|
reset: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$features/AdjustTypography', () => ({
|
vi.mock('$features/AdjustTypography', () => ({
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||||
createTypographyControlManager: vi.fn(() => ({
|
createTypographyControlManager: vi.fn(() => ({
|
||||||
@@ -89,15 +99,6 @@ vi.mock('$features/AdjustTypography', () => ({
|
|||||||
renderedSize: 48,
|
renderedSize: 48,
|
||||||
reset: vi.fn(),
|
reset: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}));
|
|
||||||
|
|
||||||
const mockTypography = vi.hoisted(() => ({
|
|
||||||
weight: 400,
|
|
||||||
renderedSize: 48,
|
|
||||||
reset: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$features/AdjustTypography/model', () => ({
|
|
||||||
getTypographySettingsStore: () => mockTypography,
|
getTypographySettingsStore: () => mockTypography,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ const side = $derived<Side>(comparisonStore.side);
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<!-- ── Header: title + A/B toggle ────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
p-6 shrink-0
|
p-6 shrink-0
|
||||||
@@ -96,14 +95,13 @@ const side = $derived<Side>(comparisonStore.side);
|
|||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Main: content area (no scroll - VirtualList handles scrolling) ─────────────────────────────── -->
|
<!-- No scroll here; VirtualList handles scrolling -->
|
||||||
<div class="flex-1 min-h-0 surface-canvas">
|
<div class="flex-1 min-h-0 surface-canvas">
|
||||||
{#if main}
|
{#if main}
|
||||||
{@render main()}
|
{@render main()}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Bottom: fixed controls ─────────────────────────────────────── -->
|
|
||||||
{#if controls}
|
{#if controls}
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
|
|||||||
@@ -16,8 +16,11 @@
|
|||||||
* - Desktop Large (>= 1280px): 4 columns
|
* - Desktop Large (>= 1280px): 4 columns
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import {
|
||||||
import { responsiveManager } from '$shared/lib';
|
createPersistentStore,
|
||||||
|
createSingleton,
|
||||||
|
responsiveManager,
|
||||||
|
} from '$shared/lib';
|
||||||
|
|
||||||
export type LayoutMode = 'list' | 'grid';
|
export type LayoutMode = 'list' | 'grid';
|
||||||
|
|
||||||
@@ -144,22 +147,25 @@ class LayoutManager {
|
|||||||
this.#mode = DEFAULT_CONFIG.mode;
|
this.#mode = DEFAULT_CONFIG.mode;
|
||||||
this.#store.clear();
|
this.#store.clear();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let _layoutManager: LayoutManager | undefined;
|
/**
|
||||||
|
* Dispose the persistent store's save effect. Call on store disposal.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.#store.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide layout manager, created on first access. Lazy so its persisted
|
* App-wide layout manager, created on first access. Lazy so its persisted
|
||||||
* layout preference isn't read at module load.
|
* layout preference isn't read at module load.
|
||||||
*/
|
*/
|
||||||
export function getLayoutManager(): LayoutManager {
|
const layoutManager = createSingleton(() => new LayoutManager(), instance => instance.destroy());
|
||||||
return (_layoutManager ??= new LayoutManager());
|
|
||||||
}
|
export const getLayoutManager = layoutManager.get;
|
||||||
|
|
||||||
// test-only reset, so specs don't share persisted layout state
|
// test-only reset, so specs don't share persisted layout state
|
||||||
export function __resetLayoutManager() {
|
export const __resetLayoutManager = layoutManager.reset;
|
||||||
_layoutManager = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export class for testing purposes
|
// Export class for testing purposes
|
||||||
export { LayoutManager };
|
export { LayoutManager };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
|
FontSampler,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
createFontRowSizeResolver,
|
createFontRowSizeResolver,
|
||||||
getFontCatalog,
|
getFontCatalog,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
TypographyMenu,
|
TypographyMenu,
|
||||||
getTypographySettingsStore,
|
getTypographySettingsStore,
|
||||||
} from '$features/AdjustTypography';
|
} from '$features/AdjustTypography';
|
||||||
import { FontSampler } from '$features/DisplayFont';
|
|
||||||
import { throttle } from '$shared/lib/utils';
|
import { throttle } from '$shared/lib/utils';
|
||||||
import { Skeleton } from '$shared/ui';
|
import { Skeleton } from '$shared/ui';
|
||||||
import { getLayoutManager } from '../../model';
|
import { getLayoutManager } from '../../model';
|
||||||
@@ -127,7 +127,7 @@ const fontRowHeight = $derived.by(() =>
|
|||||||
getFontStatus reads a $state SvelteMap, so the row stays reactive.
|
getFontStatus reads a $state SvelteMap, so the row stays reactive.
|
||||||
-->
|
-->
|
||||||
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
|
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
|
||||||
<FontSampler bind:text {font} {index} {status} />
|
<FontSampler bind:text {font} {index} {status} typography={typographySettingsStore} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FontVirtualList>
|
</FontVirtualList>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
|
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"paths": {
|
"paths": {
|
||||||
"$lib/*": ["./src/lib/*"],
|
|
||||||
"$app/*": ["./src/app/*"],
|
"$app/*": ["./src/app/*"],
|
||||||
"$widgets/*": ["./src/widgets/*"],
|
"$widgets/*": ["./src/widgets/*"],
|
||||||
"$shared/*": ["./src/shared/*"],
|
"$shared/*": ["./src/shared/*"],
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default defineConfig({
|
|||||||
plugins: [svelte(), tailwindcss()],
|
plugins: [svelte(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: '/src/lib',
|
|
||||||
$app: '/src/app',
|
$app: '/src/app',
|
||||||
$shared: '/src/shared',
|
$shared: '/src/shared',
|
||||||
$entities: '/src/entities',
|
$entities: '/src/entities',
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export default defineConfig({
|
|||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve(__dirname, './src/lib'),
|
|
||||||
$app: path.resolve(__dirname, './src/app'),
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
$shared: path.resolve(__dirname, './src/shared'),
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
$entities: path.resolve(__dirname, './src/entities'),
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
conditions: ['browser'],
|
conditions: ['browser'],
|
||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve(__dirname, './src/lib'),
|
|
||||||
$app: path.resolve(__dirname, './src/app'),
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
$shared: path.resolve(__dirname, './src/shared'),
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
$entities: path.resolve(__dirname, './src/entities'),
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
conditions: process.env.VITEST ? ['browser'] : undefined,
|
conditions: process.env.VITEST ? ['browser'] : undefined,
|
||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve(__dirname, './src/lib'),
|
|
||||||
$app: path.resolve(__dirname, './src/app'),
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
$shared: path.resolve(__dirname, './src/shared'),
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
$entities: path.resolve(__dirname, './src/entities'),
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
import { cleanup } from '@testing-library/svelte';
|
import { cleanup } from '@testing-library/svelte';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,7 @@ expect.extend(matchers);
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
queryClient.clear();
|
getQueryClient().clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock window.matchMedia for components that use it
|
// Mock window.matchMedia for components that use it
|
||||||
|
|||||||
Reference in New Issue
Block a user