Compare commits

..

16 Commits

Author SHA1 Message Date
Ilia Mashkov 84ac886c33 chore: fix TS alias resolution and SVG mocking for test setup 2026-04-22 09:45:51 +03:00
Ilia Mashkov a60dbcfa51 test: track missing component test configuration 2026-04-22 09:42:59 +03:00
Ilia Mashkov 8fc8a7ee6f test: fix component tests by adding localStorage mock and resolving store interference 2026-04-22 09:42:00 +03:00
Ilia Mashkov cbc978df6d chore(ci): add unit and component tests to lefthook and gitea workflow 2026-04-22 09:09:21 +03:00
Ilia Mashkov 6664beec25 feat(FontList): unified skeleton — rows stay skeletal until font file loaded 2026-04-22 09:02:32 +03:00
Ilia Mashkov a801903fd3 feat(FontList): use getSkeletonWidth utility for skeleton row widths 2026-04-22 09:02:32 +03:00
Ilia Mashkov ecdb1e016d feat(FontApplicator): add skeleton snippet prop to replace blur loading state 2026-04-22 09:02:32 +03:00
Ilia Mashkov 092b58e651 feat(FontVirtualList): suppress font loading during jump scroll catch-up 2026-04-22 09:02:32 +03:00
Ilia Mashkov d6914f8179 feat(FontStore): add fetchAllPagesTo for parallel batch page loading 2026-04-22 09:01:45 +03:00
Ilia Mashkov b831861662 feat(VirtualList): add onJump callback for scroll-beyond-loaded detection 2026-04-22 09:01:45 +03:00
Ilia Mashkov 67fc9dee72 fix(FontList): address the bug with selected font transition animations 2026-04-20 13:36:05 +03:00
Ilia Mashkov a73bd75947 refactor(ComparisonView): unify pretext font string generation with a utility function 2026-04-20 11:13:54 +03:00
Ilia Mashkov 836b83f75d style: apply new dprint rules to CharacterComparisonEngine 2026-04-20 11:06:54 +03:00
Ilia Mashkov 07e4a0b9d9 chore: forbid one-line and braceless cycles in dprint config 2026-04-20 11:06:45 +03:00
Ilia Mashkov 141126530d fix(ComparisonView): fix character morphing thresholds and add tracking support 2026-04-20 10:52:28 +03:00
Ilia Mashkov f9f96e2797 fix(ComparisonView): add correct line-height calculation 2026-04-20 10:51:41 +03:00
29 changed files with 835 additions and 214 deletions
+6
View File
@@ -43,6 +43,12 @@ jobs:
- name: Type Check
run: yarn check
- name: Run Unit Tests
run: yarn test:unit
- name: Run Component Tests
run: yarn test:component
publish:
needs: build # Only runs if tests/lint pass
runs-on: ubuntu-latest
+6 -1
View File
@@ -33,10 +33,15 @@
"exportDeclaration.forceMultiLine": "whenMultiple",
"exportDeclaration.forceSingleLine": false,
"ifStatement.useBraces": "always",
"ifStatement.singleBodyPosition": "nextLine",
"whileStatement.useBraces": "always",
"whileStatement.singleBodyPosition": "nextLine",
"forStatement.useBraces": "always",
"forStatement.singleBodyPosition": "nextLine",
"forInStatement.useBraces": "always",
"forOfStatement.useBraces": "always"
"forInStatement.singleBodyPosition": "nextLine",
"forOfStatement.useBraces": "always",
"forOfStatement.singleBodyPosition": "nextLine"
},
"json": {
"indentWidth": 2,
+4
View File
@@ -13,6 +13,10 @@ pre-commit:
pre-push:
parallel: true
commands:
test-unit:
run: yarn test:unit
test-component:
run: yarn test:component
type-check:
run: yarn tsc --noEmit
+6
View File
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll.
*/
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
@@ -561,4 +561,67 @@ describe('FontStore', () => {
store.destroy();
});
});
describe('fetchAllPagesTo', () => {
beforeEach(() => {
fetch.mockReset();
queryClient.clear();
});
it('fetches all missing pages in parallel up to targetIndex', async () => {
// First page already loaded (offset 0, limit 10, total 50)
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
expect(store.fonts).toHaveLength(10);
// Mock remaining pages
for (let offset = 10; offset < 50; offset += 10) {
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
);
}
await store.fetchAllPagesTo(40);
flushSync();
expect(store.fonts).toHaveLength(50);
});
it('skips pages that fail and still merges successful ones', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
// offset=10 fails, offset=20 succeeds
fetch.mockRejectedValueOnce(new Error('network error'));
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
);
await store.fetchAllPagesTo(25);
flushSync();
// Page at offset=20 merged, page at offset=10 missing — 20 total
expect(store.fonts).toHaveLength(20);
});
it('is a no-op when target is within already-loaded data', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
const callsBefore = fetch.mock.calls.length;
await store.fetchAllPagesTo(5);
expect(fetch.mock.calls.length).toBe(callsBefore);
});
});
});
@@ -242,6 +242,80 @@ export class FontStore {
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
#isCatchingUp = false;
#inFlightOffsets = new Set<number>();
/**
* Fetch all pages between the current loaded count and targetIndex in parallel.
* Pages are merged into the cache as they arrive (sorted by offset).
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
*/
async fetchAllPagesTo(targetIndex: number): Promise<void> {
if (this.#isCatchingUp) {
return;
}
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
// Collect offsets for all missing and not-in-flight pages
const missingOffsets: number[] = [];
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
missingOffsets.push(offset);
}
}
if (missingOffsets.length === 0) {
return;
}
this.#isCatchingUp = true;
// Sorted merge buffer — flush in offset order as pages arrive
const buffer = new Map<number, ProxyFontsResponse>();
const failed = new Set<number>();
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
const flush = () => {
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
if (buffer.has(nextFlushOffset)) {
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
buffer.delete(nextFlushOffset);
}
failed.delete(nextFlushOffset);
nextFlushOffset += pageSize;
}
};
try {
await Promise.allSettled(
missingOffsets.map(async offset => {
this.#inFlightOffsets.add(offset);
try {
const page = await this.fetchPage({ ...this.#params, offset });
buffer.set(offset, page);
} catch {
failed.add(offset);
} finally {
this.#inFlightOffsets.delete(offset);
}
flush();
}),
);
} finally {
this.#isCatchingUp = false;
}
}
/**
* Backward pagination (no-op: infinite scroll accumulates forward only)
*/
@@ -289,6 +363,34 @@ export class FontStore {
return this.fonts.filter(f => f.category === 'monospace');
}
/**
* Merge a single page into the InfiniteQuery cache in offset order.
* Called by fetchAllPagesTo as each parallel fetch resolves.
*/
#appendPageToCache(page: ProxyFontsResponse): void {
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
// Guard against duplicates
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
if (loadedOffsets.has(page.offset)) {
return;
}
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
(a, b) => a.offset - b.offset,
);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
pages: allPages,
pageParams: allParams,
});
}
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};
@@ -1,14 +1,11 @@
<!--
Component: FontApplicator
Loads fonts from fontshare with link tag
- Loads font only if it's not already applied
- Reacts to font load status to show/hide content
- Adds smooth transition when font appears
Applies a font to its children once the font file is loaded.
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
-->
<script lang="ts">
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
@@ -33,6 +30,11 @@ interface Props {
* Content snippet
*/
children?: Snippet;
/**
* Shown while the font file is loading.
* When omitted, children render in system font until ready.
*/
skeleton?: Snippet;
}
let {
@@ -40,6 +42,7 @@ let {
weight = DEFAULT_FONT_WEIGHT,
className,
children,
skeleton,
}: Props = $props();
const status = $derived(
@@ -50,30 +53,16 @@ const status = $derived(
),
);
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script>
<div
style:font-family={shouldReveal
? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={clsx(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',
className,
)}
>
{@render children?.()}
</div>
{#if !shouldReveal && skeleton}
{@render skeleton()}
{:else}
<div
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
class={clsx(className)}
>
{@render children?.()}
</div>
{/if}
@@ -4,6 +4,7 @@
- Handles font registration with the manager
-->
<script lang="ts">
import { debounce } from '$shared/lib/utils';
import {
Skeleton,
VirtualList,
@@ -54,6 +55,10 @@ const isLoading = $derived(
);
let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
@@ -61,8 +66,32 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
onVisibleItemsChange?.(items);
}
/**
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) {
return;
}
isCatchingUp = true;
try {
await fontStore.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
}
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs);
}, 150);
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
if (isCatchingUp) {
return;
}
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) {
@@ -71,7 +100,7 @@ $effect(() => {
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
debouncedTouch(configs);
}
});
@@ -113,17 +142,19 @@ function loadMore() {
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !fontStore.isFetching) {
// VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
loadMore();
}
}
</script>
<div class="relative w-full h-full">
{#if skeleton && isLoading && fontStore.fonts.length === 0}
{#if showInitialSkeleton && skeleton}
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
<div transition:fade={{ duration: 300 }}>
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
{@render skeleton()}
</div>
{:else}
@@ -131,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) {
<VirtualList
items={fontStore.fonts}
total={fontStore.pagination.total}
isLoading={isLoading}
isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
onJump={handleJump}
{...rest}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>
{#if showCatchupSkeleton && skeleton}
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
{@render skeleton()}
</div>
{/if}
{/if}
</div>
+1
View File
@@ -4,6 +4,7 @@ export {
mapManagerToParams,
} from './lib';
export { filtersStore } from './model/state/filters.svelte';
export { filterManager } from './model/state/manager.svelte';
export {
@@ -1,29 +1,40 @@
import { filterManager } from '$features/GetFonts';
import {
filterManager,
filtersStore,
} from '$features/GetFonts';
import {
render,
screen,
} from '@testing-library/svelte';
import { vi } from 'vitest';
import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
filterManager.setGroups([]);
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Rendering', () => {
it('renders nothing when filter groups are empty', () => {
const { container } = render(Filters);
expect(container.firstElementChild).toBeNull();
// It might render an empty container if the component has one, but we expect no children
expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
});
it('renders a label for each filter group', () => {
filterManager.setGroups([
{ id: 'cat', label: 'Category', properties: [] },
{ id: 'prov', label: 'Provider', properties: [] },
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
render(Filters);
expect(screen.getByText('Category')).toBeInTheDocument();
expect(screen.getByText('Provider')).toBeInTheDocument();
expect(screen.getByText('Categories')).toBeInTheDocument();
expect(screen.getByText('Font Providers')).toBeInTheDocument();
});
it('renders filter properties within groups', () => {
@@ -95,11 +95,13 @@ export class CharacterComparisonEngine {
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
#lastResult = $state<ComparisonResult | null>(null);
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
@@ -116,6 +118,8 @@ export class CharacterComparisonEngine {
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
@@ -124,12 +128,21 @@ export class CharacterComparisonEngine {
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = 16,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
@@ -140,11 +153,13 @@ export class CharacterComparisonEngine {
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
@@ -175,7 +190,6 @@ export class CharacterComparisonEngine {
continue;
}
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = intA.breakableFitAdvances[sIdx];
@@ -186,8 +200,12 @@ export class CharacterComparisonEngine {
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
@@ -219,66 +237,92 @@ export class CharacterComparisonEngine {
}
/**
* Calculates character proximity and direction relative to a slider position.
* Calculates character states for an entire line in a single sequential pass.
*
* Uses the most recent `layout()` result must be called after `layout()`.
* No DOM calls are made; all geometry is derived from cached layout data.
* Walks characters left-to-right, accumulating the running x position using
* each character's actual rendered width: `widthB` for already-morphed characters
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
* aligned with the visual DOM layout even when the two fonts have different widths.
*
* @param lineIndex Zero-based index of the line within the last layout result.
* @param charIndex Zero-based index of the character within that line's `chars` array.
* @param line A single laid-out line from the last layout result.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
* `isPast` (true when the slider has already passed the char center).
* @param containerWidth Total container width in pixels.
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
*/
getCharState(
lineIndex: number,
charIndex: number,
getLineCharStates(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): { proximity: number; isPast: boolean } {
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
return { proximity: 0, isPast: false };
): Array<{ proximity: number; isPast: boolean }> {
if (!line) {
return [];
}
const line = this.#lastResult.lines[lineIndex];
const char = line.chars[charIndex];
if (!char) {
return { proximity: 0, isPast: false };
}
// Center the comparison on the unified width
// In the UI, lines are centered. So we need to calculate the global X.
const lineXOffset = (containerWidth - line.width) / 2;
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
const charGlobalPercent = (charCenterX / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const chars = line.chars;
const n = chars.length;
const sliderX = (sliderPos / 100) * containerWidth;
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
// Prefix sums of widthA (left chars will be past → use widthA).
// Suffix sums of widthB (right chars will not be past → use widthB).
// This lets us compute, for each char i, what the total line width and
// char center would be at the exact moment the slider crosses that char:
// left side (0..i-1) already past → font A widths
// right side (i+1..n-1) not yet past → font B widths
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0; i < n; i++) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
}
for (let i = n - 1; i >= 0; i--) {
sufB[i] = sufB[i + 1] + chars[i].widthB;
}
// Per-char threshold: slider x at which this char should toggle isPast.
const thresholds = new Float64Array(n);
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
}
// Determine isPast for each char at the current slider position.
const isPastArr = new Uint8Array(n);
for (let i = 0; i < n; i++) {
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
}
// Compute visual positions based on actual rendered widths (font A if past, B if not).
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
const xOffset = (containerWidth - totalRendered) / 2;
let currentX = xOffset;
return chars.map((char, i) => {
const isPast = isPastArr[i] === 1;
const charWidth = isPast ? char.widthA : char.widthB;
const visualCenter = currentX + charWidth / 2;
const charGlobalPercent = (visualCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const proximity = Math.max(0, 1 - distance / range);
currentX += charWidth;
return { proximity, isPast };
});
}
/**
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any;
const intB = b as any;
const unified = { ...intA };
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx);
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndFitAdvances[i])
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndPaintAdvances[i])
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
@@ -287,13 +331,13 @@ export class CharacterComparisonEngine {
return null;
}
if (!advA) {
return advB;
return advB.map((w: number) => w + spacingPx);
}
if (!advB) {
return advA;
return advA.map((w: number) => w + spacingPx);
}
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
@@ -109,56 +109,52 @@ describe('CharacterComparisonEngine', () => {
expect(r2).not.toBe(r1);
});
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
// 'A' only: FontA width=10. Container=500px. Line centered.
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
// charCenterX = lineXOffset + xA + widthA/2.
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
// distance = |50.5 - 50.5| = 0 => proximity = 1
it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
const containerWidth = 500;
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Recalculate expected percent manually:
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
const lineXOffset = (containerWidth - lineWidth) / 2;
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
const charPercent = (charCenterX / containerWidth) * 100;
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
// So proximity=1 at exactly 50%.
const charPercent = 50;
const state = engine.getCharState(0, 0, charPercent, containerWidth);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
expect(states[0]?.proximity).toBe(1);
expect(states[0]?.isPast).toBe(false);
});
it('getCharState returns proximity 0 when slider is far from char', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
const state = engine.getCharState(0, 0, 0, 500);
expect(state.proximity).toBe(0);
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 0, 500);
expect(states[0]?.proximity).toBe(0);
});
it('getCharState isPast is true when slider has passed char center', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 0, 100, 500);
expect(state.isPast).toBe(true);
it('getLineCharStates isPast is true when slider has passed char center', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 100, 500);
expect(states[0]?.isPast).toBe(true);
});
it('getCharState returns safe default for out-of-range lineIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(99, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Passing an undefined object because the index doesn't exist.
const states = engine.getLineCharStates(result.lines[99], 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default for out-of-range charIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 99, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array before layout() has been called', () => {
// Passing an undefined object because layout() hasn't been called.
const states = engine.getLineCharStates(undefined as any, 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default before layout() has been called', () => {
const state = engine.getCharState(0, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns safe defaults for all chars', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 50, 500);
expect(states.length).toBeGreaterThan(0);
for (const s of states) {
expect(s.proximity).toBeGreaterThanOrEqual(0);
expect(s.proximity).toBeLessThanOrEqual(1);
expect(typeof s.isPast).toBe('boolean');
}
});
});
@@ -0,0 +1,13 @@
/**
* Generates a consistent but varied width for skeleton placeholders.
* Uses a predefined sequence to ensure stability between renders.
*
* @param index - Index of the item in a list to pick a width from the sequence
* @param multiplier - Multiplier to apply to the base sequence values (default: 4)
* @returns CSS width value (e.g., "128px")
*/
export function getSkeletonWidth(index: number, multiplier = 4): string {
const sequence = [32, 48, 40, 56, 36, 44, 52, 38, 46, 42, 34, 50];
const base = sequence[index % sequence.length];
return `${base * multiplier}px`;
}
+1
View File
@@ -17,6 +17,7 @@ export {
export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll';
export { splitArray } from './splitArray/splitArray';
@@ -114,7 +114,7 @@ describe('ComboControl', () => {
it('opens popover with vertical slider on trigger click', async () => {
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
await fireEvent.click(screen.getByLabelText('Size control'));
await fireEvent.click(screen.getByText('Size control'));
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
});
});
@@ -62,6 +62,10 @@ interface Props extends
* Near bottom callback
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Fires when scroll position exceeds loaded content — user jumped beyond data
*/
onJump?: (targetIndex: number) => void;
/**
* Item render snippet
*/
@@ -95,6 +99,7 @@ let {
class: className,
onVisibleItemsChange,
onNearBottom,
onJump,
children,
useWindowScroll = false,
isLoading = false,
@@ -170,6 +175,10 @@ const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms throttle
const throttledOnJump = throttle((targetIndex: number) => {
onJump?.(targetIndex);
}, 200);
// Calculate top/bottom padding for spacer elements
// In CSS Grid, gap creates space BETWEEN elements.
// The top spacer should place the first row at its virtual offset.
@@ -227,6 +236,26 @@ $effect(() => {
}
}
});
$effect(() => {
// Fire onJump when scroll is beyond the loaded content boundary.
// Target index estimates which item the user scrolled to.
if (!onJump || !virtualizer.containerHeight || virtualizer.scrollOffset <= 0) {
return;
}
const isAhead = virtualizer.scrollOffset > virtualizer.totalSize;
if (!isAhead) {
return;
}
const estimatedItemHeight = typeof itemHeight === 'number' ? itemHeight : 80;
// Include visible rows + overscan so the bottom of the viewport is fully covered
const topItemIndex = Math.floor(virtualizer.scrollOffset / estimatedItemHeight) * columns;
const visibleRows = Math.ceil(virtualizer.containerHeight / estimatedItemHeight);
const targetIndex = topItemIndex + (visibleRows + overscan) * columns;
throttledOnJump(targetIndex);
});
</script>
{#snippet content()}
+2
View File
@@ -0,0 +1,2 @@
export * from './utils/dotTransition';
export * from './utils/getPretextFontString';
@@ -0,0 +1,99 @@
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
import { cubicOut } from 'svelte/easing';
import {
type CrossfadeParams,
type TransitionConfig,
crossfade,
} from 'svelte/transition';
/**
* Custom parameters for dot transitions in virtualized lists.
*/
export interface DotTransitionParams extends CrossfadeParams {
/**
* Unique key for crossfade pairing
*/
key: any;
/**
* Current index of the item in the list
*/
index: number;
/**
* Target index to move towards (e.g. counterpart side index)
*/
otherIndex: number;
}
/**
* Type-safe helper to create dot transition parameters.
*/
export function getDotTransitionParams(
key: 'active-dot' | 'inactive-dot',
index: number,
otherIndex: number,
): DotTransitionParams {
return { key, index, otherIndex };
}
/**
* Type guard for DotTransitionParams.
*/
function isDotTransitionParams(p: CrossfadeParams): p is DotTransitionParams {
return (
p !== null
&& typeof p === 'object'
&& 'index' in p
&& 'otherIndex' in p
);
}
/**
* Creates a crossfade transition pair optimized for virtualized font lists.
*
* It uses the 'index' and 'otherIndex' params to calculate the direction
* of the slide animation when a matching pair cannot be found in the DOM
* (e.g. because it was virtualized out).
*/
export function createDotCrossfade() {
return crossfade({
duration: 300,
easing: cubicOut,
fallback(node: Element, params: CrossfadeParams, _intro: boolean): TransitionConfig {
if (!isDotTransitionParams(params)) {
return {
duration: 300,
easing: cubicOut,
css: t => `opacity: ${t};`,
};
}
const { index, otherIndex } = params;
// If the other target is unknown, just fade in place
if (otherIndex === undefined || otherIndex === -1) {
return {
duration: 300,
easing: cubicOut,
css: t => `opacity: ${t};`,
};
}
const diff = otherIndex - index;
const sign = diff > 0 ? 1 : (diff < 0 ? -1 : 0);
// Use container height for a full-height slide
const listEl = node.closest('[data-font-list]');
const h = listEl?.clientHeight ?? 300;
const fromY = sign * h;
return {
duration: 300,
easing: cubicOut,
css: (t, u) => `
transform: translateY(${fromY * u}px);
opacity: ${t};
`,
};
},
});
}
@@ -0,0 +1,35 @@
import {
describe,
expect,
it,
} from 'vitest';
import { getPretextFontString } from './getPretextFontString';
describe('getPretextFontString', () => {
it('correctly formats the font string for pretext', () => {
const weight = 400;
const sizePx = 16;
const fontName = 'Inter';
const expected = '400 16px "Inter"';
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
});
it('works with different weight and size', () => {
const weight = 700;
const sizePx = 32;
const fontName = 'Roboto';
const expected = '700 32px "Roboto"';
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
});
it('handles font names with spaces', () => {
const weight = 400;
const sizePx = 16;
const fontName = 'Open Sans';
const expected = '400 16px "Open Sans"';
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
});
});
@@ -0,0 +1,11 @@
/**
* Formats a font configuration into a string format required by @chenglou/pretext.
*
* @param weight - Numeric font weight (e.g., 400).
* @param sizePx - Font size in pixels.
* @param fontName - The font family name.
* @returns A formatted font string: `"weight sizepx \"fontName\""`.
*/
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
return `${weight} ${sizePx}px "${fontName}"`;
}
@@ -24,6 +24,7 @@ import {
import { typographySettingsStore } from '$features/SetupFont/model';
import { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib';
/**
* Storage schema for comparison state
@@ -205,8 +206,8 @@ export class ComparisonStore {
return;
}
const fontAString = `${weight} ${size}px "${fontAName}"`;
const fontBString = `${weight} ${size}px "${fontBName}"`;
const fontAString = getPretextFontString(weight, size, fontAName);
const fontBString = getPretextFontString(weight, size, fontBName);
// Check if already loaded to avoid UI flash
const isALoaded = document.fonts.check(fontAString);
@@ -48,6 +48,7 @@ $effect(() => {
<span
class="char-wrap"
style:font-size="{typography.renderedSize}px"
style:margin-right="{typography.spacing}em"
style:will-change={proximity > 0 ? 'transform' : 'auto'}
>
{#each [0, 1] as s (s)}
@@ -8,65 +8,86 @@ import {
FontApplicator,
FontVirtualList,
type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED,
appliedFontsManager,
fontStore,
} from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils';
import {
Button,
Label,
Skeleton,
} from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import { comparisonStore } from '../../model';
import { fade } from 'svelte/transition';
import {
createDotCrossfade,
getDotTransitionParams,
} from '../../lib';
import {
type Side,
comparisonStore,
} from '../../model';
const side = $derived(comparisonStore.side);
let prevIndexA: number | null = null;
let prevIndexB: number | null = null;
let selectedIndexA: number | null = null;
let selectedIndexB: number | null = null;
let pendingDirection: 1 | -1 = 1;
const [send, receive] = crossfade({
duration: 300,
easing: cubicOut,
fallback(node) {
// Read pendingDirection synchronously — no reactive timing issues
const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300);
return {
duration: 300,
easing: cubicOut,
css: t => `transform: translateY(${fromY * (1 - t)}px);`,
};
},
// Treat -1 (not loaded) as being at the very bottom of the infinite list
function getVirtualIndex(fontId: string | undefined): number {
if (!fontId) {
return -1;
}
const idx = fontStore.fonts.findIndex(f => f.id === fontId);
if (idx === -1) {
return VIRTUAL_INDEX_NOT_LOADED;
}
return idx;
}
// Reactive indices of the currently selected fonts in the full list
const indexA = $derived(getVirtualIndex(comparisonStore.fontA?.id));
const indexB = $derived(getVirtualIndex(comparisonStore.fontB?.id));
// Track previous state for directional fallback transitions.
// We use plain variables here. In Svelte 5, updates to these in an $effect
// happen AFTER the render/DOM update, so transitions starting as a result
// of that update will see the "old" values of these variables.
let prevIndexA = indexA;
let prevIndexB = indexB;
let prevSide: Side = side;
const [send, receive] = createDotCrossfade();
$effect(() => {
// This effect runs after every change to indexA, indexB, or side.
// It captures the "current" values which will serve as "previous" values
// for the NEXT transition.
prevIndexA = indexA;
prevIndexB = indexB;
prevSide = side;
});
function handleSelect(font: UnifiedFont, index: number) {
function handleSelect(font: UnifiedFont) {
if (side === 'A') {
if (prevIndexA !== null) {
pendingDirection = index > prevIndexA ? -1 : 1;
}
prevIndexA = index;
selectedIndexA = index;
comparisonStore.fontA = font;
} else if (side === 'B') {
if (prevIndexB !== null) {
pendingDirection = index > prevIndexB ? -1 : 1;
}
prevIndexB = index;
selectedIndexB = index;
} else {
comparisonStore.fontB = font;
}
}
// When side switches, compute direction from relative positions of A vs B
$effect(() => {
const _ = side; // track side
if (selectedIndexA !== null && selectedIndexB !== null) {
// Switching TO B means dot moves toward B's position relative to A
pendingDirection = side === 'B'
? (selectedIndexB > selectedIndexA ? 1 : -1)
: (selectedIndexA > selectedIndexB ? 1 : -1);
}
});
/**
* Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside
* appliedFontsManager.getFontStatus(), so each row re-renders reactively
* when its file arrives.
*/
function isFontReady(font: UnifiedFont): boolean {
const status = appliedFontsManager.getFontStatus(
font.id,
DEFAULT_FONT_WEIGHT,
font.features?.isVariable,
);
return status === 'loaded' || status === 'error';
}
</script>
<div class="flex-1 min-h-0 h-full">
@@ -79,41 +100,93 @@ $effect(() => {
<FontVirtualList
data-font-list
weight={DEFAULT_FONT_WEIGHT}
itemHeight={45}
itemHeight={44}
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
>
{#snippet skeleton()}
<div class="py-2.5 md:py-3 px-7">
{#each { length: 50 } as _, index (index)}
<div class="w-full px-3 py-3 flex items-center justify-between">
<div class="flex-1 flex items-center gap-3">
<Skeleton
class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
</div>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{/each}
</div>
{/snippet}
{#snippet children({ item: font, index })}
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<div class="relative h-[44px] w-full">
{#if !isFontReady(font)}
<div
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
transition:fade={{ duration: 300 }}
>
<Skeleton
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{:else}
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font, index)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
iconPosition="right"
>
<FontApplicator {font}>{font.name}</FontApplicator>
<div transition:fade={{ duration: 300 }} class="h-full">
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font)}
class="w-full h-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
iconPosition="right"
>
<FontApplicator {font}>
{font.name}
</FontApplicator>
{#snippet icon()}
{#if active}
<div
in:receive={{ key: 'active-dot' }}
out:send={{ key: 'active-dot' }}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
<div
in:receive={{ key: 'inactive-dot' }}
out:send={{ key: 'inactive-dot' }}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
{#snippet icon()}
{#if active}
<div
in:receive={getDotTransitionParams(
'active-dot',
index,
prevSide === 'A' ? prevIndexA : prevIndexB,
)}
out:send={getDotTransitionParams(
'active-dot',
index,
side === 'A' ? indexA : indexB,
)}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
{@const isA = isSelectedA}
<div
in:receive={getDotTransitionParams(
'inactive-dot',
index,
isA ? prevIndexB : prevIndexA,
)}
out:send={getDotTransitionParams(
'inactive-dot',
index,
isA ? indexB : indexA,
)}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
</div>
{/if}
</div>
{/snippet}
</FontVirtualList>
</div>
@@ -33,8 +33,8 @@ let { chars, character }: Props = $props();
<div
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="{typography.height}em"
style:line-height="{typography.height}em"
style:height="{typography.height * typography.renderedSize}px"
style:line-height="{typography.height * typography.renderedSize}px"
>
{#each chars as c, index}
{@render character?.({ char: c.char, index })}
@@ -22,6 +22,7 @@ import clsx from 'clsx';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import { getPretextFontString } from '../../lib';
import { comparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import Line from '../Line/Line.svelte';
@@ -53,6 +54,7 @@ const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false);
let isTypographyMenuOpen = $state(false);
let containerWidth = $state(0);
// New high-performance layout engine
const comparisonEngine = new CharacterComparisonEngine();
@@ -127,24 +129,28 @@ $effect(() => {
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
const _spacing = typography.spacing;
if (container && fontA && fontB) {
// PRETEXT API strings: "weight sizepx family"
const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * 1.2; // Approximate
const lineHeight = _size * _height;
containerWidth = width;
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
_spacing,
_size,
);
}
});
@@ -157,12 +163,15 @@ $effect(() => {
if (container && fontA && fontB) {
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
containerWidth = width;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
getPretextFontString(typography.weight, typography.renderedSize, fontA.name),
getPretextFontString(typography.weight, typography.renderedSize, fontB.name),
width - padding,
typography.renderedSize * 1.2,
typography.renderedSize * typography.height,
typography.spacing,
typography.renderedSize,
);
}
};
@@ -239,11 +248,15 @@ const scaleClass = $derived(
my-auto
"
>
{#each layoutResult.lines as line, lineIndex}
{#each layoutResult.lines as line}
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)}
<Line chars={line.chars}>
{#snippet character({ char, index })}
{@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
<Character {char} {proximity} {isPast} />
<Character
{char}
proximity={lineStates[index]?.proximity ?? 0}
isPast={lineStates[index]?.isPast ?? false}
/>
{/snippet}
</Line>
{/each}
+2
View File
@@ -38,6 +38,8 @@
"src/**/*.js",
"src/**/*.svelte",
"src/**/*.d.ts",
"vitest.config*.ts",
"vitest.setup*.ts",
"vitest.types.d.ts"
],
"exclude": [
+29
View File
@@ -0,0 +1,29 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [svelte()],
test: {
environment: 'jsdom',
include: ['src/**/*.svelte.test.ts'],
exclude: ['node_modules', 'dist', 'e2e', '.storybook'],
restoreMocks: true,
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
globals: true,
},
resolve: {
conditions: ['browser'],
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'),
$shared: path.resolve(__dirname, './src/shared'),
$entities: path.resolve(__dirname, './src/entities'),
$features: path.resolve(__dirname, './src/features'),
$routes: path.resolve(__dirname, './src/routes'),
$widgets: path.resolve(__dirname, './src/widgets'),
},
},
});
+2
View File
@@ -1,3 +1,4 @@
import { queryClient } from '$shared/api/queryClient';
import * as matchers from '@testing-library/jest-dom/matchers';
import { cleanup } from '@testing-library/svelte';
import {
@@ -13,6 +14,7 @@ expect.extend(matchers);
afterEach(() => {
cleanup();
queryClient.clear();
});
// Mock window.matchMedia for components that use it
+46
View File
@@ -0,0 +1,46 @@
import { vi } from 'vitest';
// jsdom lacks ResizeObserver
global.ResizeObserver = class {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
// jsdom lacks Web Animations API
Element.prototype.animate = vi.fn().mockReturnValue({
onfinish: null,
cancel: vi.fn(),
finish: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
});
// jsdom lacks SVG geometry methods
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
// Robust localStorage mock for jsdom environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
key: vi.fn((index: number) => Object.keys(store)[index] || null),
get length() {
return Object.keys(store).length;
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});