Compare commits

...

5 Commits

Author SHA1 Message Date
Ilia Mashkov
dde187e0b2 chore: move ControlId type to the entities/Font layer 2026-04-16 11:19:17 +03:00
Ilia Mashkov
5a7c61ade7 feat(FontVirtualList): re-touch on weight change and pin visible fonts 2026-04-16 11:05:09 +03:00
Ilia Mashkov
d2bce85f9c feat(ComparisonStore): pin fontA/fontB to prevent eviction while on-screen 2026-04-16 10:55:41 +03:00
Ilia Mashkov
e509463911 chore: remove unused 2026-04-16 09:07:46 +03:00
Ilia Mashkov
db08f523f6 chore: move typography constants to the entity/Font layer 2026-04-16 09:05:34 +03:00
16 changed files with 149 additions and 102 deletions

View File

@@ -1,5 +1,5 @@
import type { ControlModel } from '$shared/lib'; import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..'; import type { ControlId } from '../types/typography';
/** /**
* Font size constants * Font size constants

View File

@@ -1,2 +1,3 @@
export * from './const/const';
export * from './store'; export * from './store';
export * from './types'; export * from './types';

View File

@@ -1,5 +1,5 @@
// Applied fonts manager // Applied fonts manager
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store // Batch font store
export { BatchFontStore } from './batchFontStore.svelte'; export { BatchFontStore } from './batchFontStore.svelte';

View File

@@ -33,3 +33,4 @@ export type {
} from './store'; } from './store';
export * from './store/appliedFonts'; export * from './store/appliedFonts';
export * from './typography';

View File

@@ -0,0 +1 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';

View File

@@ -10,6 +10,7 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion'; import { prefersReducedMotion } from 'svelte/motion';
import { import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
} from '../../model'; } from '../../model';
@@ -36,7 +37,7 @@ interface Props {
let { let {
font, font,
weight = 400, weight = DEFAULT_FONT_WEIGHT,
className, className,
children, children,
}: Props = $props(); }: Props = $props();

View File

@@ -18,8 +18,8 @@ import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
fontStore,
} from '../../model'; } from '../../model';
import { fontStore } from '../../model/store';
interface Props extends interface Props extends
Omit< Omit<
@@ -53,30 +53,42 @@ const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading, fontStore.isFetching || fontStore.isLoading,
); );
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { let visibleFonts = $state<UnifiedFont[]>([]);
const configs: FontLoadRequestConfig[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener // Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems); onVisibleItemsChange?.(items);
} }
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) return [];
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
}
});
// Pin visible fonts so the eviction policy never removes on-screen entries.
// Cleanup captures the snapshot values, so a weight change unpins the old
// weight before pinning the new one.
$effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
/** /**
* Load more fonts by moving to the next page * Load more fonts by moving to the next page
*/ */

View File

@@ -1,28 +1,6 @@
export { TypographyMenu } from './ui';
export { export {
type ControlId, createTypographySettingsManager,
controlManager, type TypographySettingsManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './model';
export {
createTypographyControlManager,
type TypographyControlManager,
} from './lib'; } from './lib';
export { typographySettingsStore } from './model';
export { TypographyMenu } from './ui';

View File

@@ -10,6 +10,13 @@
* when displaying/editing, but the base size is what's stored. * when displaying/editing, but the base size is what's stored.
*/ */
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '$entities/Font';
import { import {
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
@@ -19,13 +26,6 @@ import {
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '../../model';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>; type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;

View File

@@ -1,4 +1,11 @@
/** @vitest-environment jsdom */ /** @vitest-environment jsdom */
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import { import {
afterEach, afterEach,
beforeEach, beforeEach,
@@ -7,13 +14,6 @@ import {
it, it,
vi, vi,
} from 'vitest'; } from 'vitest';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '../../model';
import { import {
type TypographySettings, type TypographySettings,
TypographySettingsManager, TypographySettingsManager,

View File

@@ -1,24 +1 @@
export { export { typographySettingsStore } from './state/typographySettingsStore';
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const';
export {
type ControlId,
typographySettingsStore,
} from './state/typographySettingsStore';

View File

@@ -1,7 +1,6 @@
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
import { createTypographySettingsManager } from '../../lib'; import { createTypographySettingsManager } from '../../lib';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
export const typographySettingsStore = createTypographySettingsManager( export const typographySettingsStore = createTypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
'glyphdiff:comparison:typography', 'glyphdiff:comparison:typography',

View File

@@ -6,6 +6,11 @@
Desktop: inline bar with combo controls. Desktop: inline bar with combo controls.
--> -->
<script lang="ts"> <script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
@@ -19,12 +24,7 @@ import { Popover } from 'bits-ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { import { typographySettingsStore } from '../../model';
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
typographySettingsStore,
} from '../../model';
interface Props { interface Props {
/** /**

View File

@@ -134,6 +134,19 @@ export class ComparisonStore {
}); });
} }
}); });
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const w = typographySettingsStore.weight;
if (fa) appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
if (fb) appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
return () => {
if (fa) appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
if (fb) appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
};
});
}); });
} }

View File

@@ -53,15 +53,19 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
// ── $entities/Font mock — keep real BatchFontStore, stub singletons ─────────── // ── $entities/Font mock — keep real BatchFontStore, stub singletons ───────────
vi.mock('$entities/Font', async () => { vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
const { BatchFontStore } = await import( const { BatchFontStore } = await import(
'$entities/Font/model/store/batchFontStore.svelte' '$entities/Font/model/store/batchFontStore.svelte'
); );
return { return {
...actual,
BatchFontStore, BatchFontStore,
fontStore: { fonts: [] }, fontStore: { fonts: [] },
appliedFontsManager: { appliedFontsManager: {
touch: vi.fn(), touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(), getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()), ready: vi.fn(() => Promise.resolve()),
}, },
@@ -80,9 +84,20 @@ vi.mock('$features/SetupFont', () => ({
})), })),
})); }));
vi.mock('$features/SetupFont/model', () => ({
typographySettingsStore: {
weight: 400,
renderedSize: 48,
reset: vi.fn(),
},
}));
// ── Imports (after mocks) ───────────────────────────────────────────────────── // ── Imports (after mocks) ─────────────────────────────────────────────────────
import { fontStore } from '$entities/Font'; import {
appliedFontsManager,
fontStore,
} from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte'; import { ComparisonStore } from './comparisonStore.svelte';
@@ -209,4 +224,55 @@ describe('ComparisonStore', () => {
expect(store.fontB).toBeUndefined(); expect(store.fontB).toBeUndefined();
}); });
}); });
// ── Pin / Unpin ───────────────────────────────────────────────────────────
describe('Pin / Unpin (eviction guard)', () => {
it('pins fontA and fontB when they are loaded', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore();
await vi.waitFor(() => {
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
mockFontB.id,
400,
mockFontB.features?.isVariable,
);
}, { timeout: 2000 });
});
it('unpins the old font when fontA is replaced', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' };
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]);
store.fontA = mockFontC;
await vi.waitFor(() => {
expect(appliedFontsManager.unpin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
mockFontC.id,
400,
mockFontC.features?.isVariable,
);
}, { timeout: 2000 });
});
});
}); });

View File

@@ -4,11 +4,11 @@
--> -->
<script lang="ts"> <script lang="ts">
import { import {
DEFAULT_FONT_WEIGHT,
FontApplicator, FontApplicator,
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { typographySettingsStore } from '$features/SetupFont';
import { import {
Button, Button,
Label, Label,
@@ -19,8 +19,6 @@ import { crossfade } from 'svelte/transition';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
const side = $derived(comparisonStore.side); const side = $derived(comparisonStore.side);
const typography = $derived(typographySettingsStore);
let prevIndexA: number | null = null; let prevIndexA: number | null = null;
let prevIndexB: number | null = null; let prevIndexB: number | null = null;
let selectedIndexA: number | null = null; let selectedIndexA: number | null = null;
@@ -80,7 +78,7 @@ $effect(() => {
</div> </div>
<FontVirtualList <FontVirtualList
data-font-list data-font-list
weight={typography.weight} weight={DEFAULT_FONT_WEIGHT}
itemHeight={45} itemHeight={45}
class="bg-transparent min-h-0 h-full scroll-stable pr-4" class="bg-transparent min-h-0 h-full scroll-stable pr-4"
> >