Compare commits

...

13 Commits

Author SHA1 Message Date
Ilia Mashkov c09ca93f4e perf(test): stop typographySettings pulling @tanstack for four constants
Workflow / build (pull_request) Successful in 1m15s
Workflow / e2e (pull_request) Failing after 1m33s
Workflow / publish (pull_request) Has been skipped
typographySettingsStore (and its spec) imported DEFAULT_FONT_* from the
$entities/Font root barrel, which re-exports FontVirtualList -> stores ->
@tanstack/query-core. Under Vitest (no tree-shaking) that loaded the entire
UI + TanStack graph just to read four constants.

Import them from the pure $entities/Font/model/const/const module instead.
Deep path is deliberate: it pulls only the constants, not the entity store
graph. Mitigates the test-side cost of audit D-1 (root barrel not yet
inert); the structural fix (inject stores into FontVirtualList) stays
parked.
2026-06-03 10:52:29 +03:00
Ilia Mashkov 99ab7e9e08 refactor(shared/ui): stop deep-importing sibling config/types (C-3)
Badge and TechText reached into $shared/ui/Label/config, and SearchBar into
$shared/ui/Input/types — bypassing the siblings public surface.

- Label/config.ts was explicitly shared text-styling config, not Labels
  internals; relocate it to shared/ui/labelConfig.ts (a neutral peer module)
  and point Label/Badge/TechText at it relatively.
- inputIconSize is a consumer-facing map; export it from Input/index.ts so
  SearchBar imports it through the barrel alongside Input.
2026-06-03 10:36:15 +03:00
Ilia Mashkov ec488cf1ce docs(ComparisonView): document the injected store fields
#fontCatalog / #typography / #lifecycle lacked the per-field doc the rest
of the class uses.
2026-06-03 10:26:00 +03:00
Ilia Mashkov fe07c60dd4 refactor: adopt createSingleton across the remaining stores
Replace the hand-rolled let _x / getX / __resetX boilerplate with the
createSingleton helper in all nine remaining singleton stores. Exposed
accessor names (getX, __resetX) are unchanged, so consumers and specs are
unaffected. Teardown wired to each stores destroy() where it has one
(fontCatalog, fontLifecycle, typography, availableFilter, theme, layout,
scrollBreadcrumbs); sort and appliedFilter have no teardown. Also merges
layoutStores duplicate $shared/lib imports.
2026-06-03 10:22:41 +03:00
Ilia Mashkov 0aae710e35 fix: dispose persistent-store effects; close comparisonStore effect leak
Thread the new createPersistentStore.destroy() through its owners so the
save effect.root is actually torn down: LayoutManager gains destroy();
ThemeManager and typographySettings dispose their #store in their existing
destroy().

comparisonStore had its own leak — a constructor $effect.root whose disposer
was discarded, and a module-level persistent storage created at import. Move
storage into the instance (#storage, created lazily per instance), capture
the effect.root disposer, add destroy(), and adopt createSingleton so reset
runs resetAll() + destroy(). Re-export createSingleton from the $shared/lib
barrel; give the test storage mock a destroy().
2026-06-03 10:16:47 +03:00
Ilia Mashkov ded9606c30 fix(shared): give createPersistentStore a destroy() to dispose its effect.root
The store created an $effect.root for the save-on-change sync but returned no
disposer, so the effect leaked for the life of the process — contradicting
the rule that $effect.root owners must expose destroy(). Capture and expose
the disposer.

- add destroy() to the returned store; covered by tests (flushSync proves the
  save effect runs before destroy and stops after)
- trim the bloated header (two near-duplicate @example blocks) to one concise
  JSDoc — no fluff
- update typographySettings test mocks to satisfy the now-required destroy()

Consumers (LayoutManager, ThemeManager, typographySettings, comparisonStore)
do not yet call it — threading + the createSingleton migration follow.
2026-06-03 10:00:21 +03:00
Ilia Mashkov f0736f4d35 feat(shared): add createSingleton lazy-singleton accessor helper
Standardizes the getX() / __resetX() pattern hand-rolled identically across
every store: lazy construction on first get(), memoized thereafter, and a
reset() that runs an optional teardown (e.g. destroy()) and clears so the
next get() rebuilds. Lazy by construction, so owning modules stay inert at
import. Covered by unit tests (laziness, memoization, rebuild-after-reset,
teardown-once-with-live-instance, reset-before-get no-op, falsy-value
caching).

Not yet adopted by the stores — that migration is a separate step.
2026-06-03 09:39:51 +03:00
Ilia Mashkov 5eb458eabb refactor(ComparisonView): import typography store via root barrel
comparisonStore reached getTypographySettingsStore through
$features/AdjustTypography/model (deep, past the public API) while the
catalog/lifecycle accessors already go through the $entities/Font root
barrel. Re-path to $features/AdjustTypography so all three composed-store
imports go through public APIs uniformly. Direction was always legal
(widget -> feature, downward); this only closes the deep-import
inconsistency.

Consolidate the spec mock onto the root module accordingly and drop the
now-dead /model mock.
2026-06-03 08:57:04 +03:00
Ilia Mashkov a428eac309 docs: remove decorative separator comments
Strip box-drawing (──) section dividers and ===/banner headers — visual
noise with no information. Where a divider label carried a non-obvious
why (VirtualList owns scrolling; mobile footer is md:hidden because header
stats take over) it is kept as a plain one-line comment; pure restatements
of the markup (Header bar, Red hover line, Bottom: fixed controls) are
dropped. Single comment style, no fluff.
2026-06-03 08:45:36 +03:00
Ilia Mashkov 09869aed00 refactor(entities/Font): relocate FontSampler from DisplayFont, invert typography
DisplayFont was not a feature (FSD+ A-6): the whole slice was one
presentational component that renders a Font styled by typography, with no
model/domain/action. To get typography it reached sideways into a sibling
feature (`$features/AdjustTypography/model`) — a feature->feature edge
(C-1), the symptom of the mislayering, not the disease.

Fix by inversion, mirroring the existing `status` prop pattern:

- move FontSampler into entities/Font/ui (it now uses only entity siblings
  + $shared/ui)
- it accepts a `typography` prop typed to a minimal contract defined in the
  component; the AdjustTypography store satisfies it structurally, so the
  entity has no dependency on the feature
- SampleList (owns both) injects its typographySettingsStore as the prop
- delete the DisplayFont slice; export FontSampler from the Font barrel;
  relocate the story (now passes a mock typography)

Resolves A-6, A-7, and the FontSampler half of C-1. Verified: 0 type
errors, 0 lint (boundary rule satisfied), 905 unit + 213 component tests,
production build OK.
2026-06-03 08:34:49 +03:00
Ilia Mashkov 028853aff5 chore: drop stale bindings.svelte.ts from sideEffects allowlist
bindings.svelte.ts no longer has a top-level side effect: the $effect.root
bridge was moved into startFilterBindings(), wired explicitly by the
app-layer AppBindings provider (onMount). Nothing imports it
side-effect-only anymore, so the allowlist entry falsely marked a now-pure
module as impure. Stores and queryClient are lazy getX() accessors, so they
correctly need no entry either.

Allowlist is now just *.css (style injection) and **/router.ts
(createRouter at eval). Verified: production build succeeds and
startFilterBindings is retained as a used export.
2026-06-02 23:34:08 +03:00
Ilia Mashkov 1c6427c586 chore: drop vestigial $lib alias
$lib pointed at src/lib/, which does not exist, and nothing imported it.
Removed the dead alias from all five declaration sites (tsconfig plus the
vite and three vitest configs). A stray $lib import now fails fast as an
unknown alias instead of resolving to a missing path.
2026-06-02 23:17:27 +03:00
Ilia Mashkov 60e115309c refactor(shared/api): lazify queryClient to remove eager-construction footgun
queryClient.ts constructed the TanStack client at module eval but was not
in the sideEffects allowlist, so Rollup treated the module as pure — safe
only while a value-importer keeps it alive (D-3).

- replace the eager `queryClient` singleton with a memoized getQueryClient()
  factory; construction is deferred to first call
- the module is now genuinely side-effect-free, so no sideEffects exception
  is needed and construction can never be legally tree-shaken away
- route all consumers through getQueryClient() (QueryProvider as first
  caller; stores via class fields/observers; tests via a local alias; the
  fontCatalogStore spec mock now overrides getQueryClient)
2026-06-02 23:13:03 +03:00
53 changed files with 522 additions and 297 deletions
+1 -2
View File
@@ -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",
+4 -1
View File
@@ -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 {
+2 -2
View File
@@ -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);
}); });
} }
+1
View File
@@ -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 -5
View File
@@ -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 -4
View File
@@ -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
@@ -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>)}
@@ -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
+2
View File
@@ -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,
}; };
@@ -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;
}
@@ -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
View File
@@ -1 +0,0 @@
export { FontSampler } from './ui';
-3
View File
@@ -1,3 +0,0 @@
import FontSampler from './FontSampler/FontSampler.svelte';
export { FontSampler };
@@ -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;
}
@@ -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;
}
@@ -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;
}
+12 -5
View File
@@ -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,7 +44,8 @@ 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 {
return (queryClientInstance ??= new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS, staleTime: DEFAULT_QUERY_STALE_TIME_MS,
@@ -65,4 +71,5 @@ export const queryClient = new QueryClient({
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS), 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;
},
};
}
+14
View File
@@ -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
+2
View File
@@ -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 -5
View File
@@ -1,9 +1,5 @@
/** /**
* ============================================================================ * Storybook helpers: components and utilities for stories.
* STORYBOOK HELPERS
* ============================================================================
*
* Helper components and utilities for Storybook stories.
* *
* ## Usage * ## Usage
* *
+3 -3
View File
@@ -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
View File
@@ -1 +1,2 @@
export { default as Input } from './Input.svelte'; export { default as Input } from './Input.svelte';
export { inputIconSize } from './types';
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type LabelVariant, type LabelVariant,
labelSizeConfig, labelSizeConfig,
labelVariantConfig, labelVariantConfig,
} from './config'; } from '../labelConfig';
interface Props { interface Props {
/** /**
+4 -2
View File
@@ -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.
+2 -2
View File
@@ -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>
-1
View File
@@ -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/*"],
-1
View File
@@ -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',
-1
View File
@@ -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'),
-1
View File
@@ -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'),
-1
View File
@@ -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'),
+2 -2
View File
@@ -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